diff --git a/.env.example b/.env.example index 22d64c9..4467848 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,14 @@ WHATSAPP_PROVIDER=console EMAIL_ENABLED=True EMAIL_PROVIDER=console +# Twilio Configuration +# Set SMS_PROVIDER=twilio and fill in credentials to send real SMS +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_PHONE_NUMBER= +# Alternatively, use a Messaging Service SID (for sender phone number pooling) +TWILIO_MESSAGING_SERVICE_SID= + # External API Notification Configuration # Email API @@ -56,6 +64,12 @@ SMS_API_TIMEOUT=10 SMS_API_MAX_RETRIES=3 SMS_API_RETRY_DELAY=2 +# Mshastra SMS API +# Set SMS_PROVIDER=mshastra and fill in credentials to send real SMS via Mshastra +MSHASTRA_USERNAME= +MSHASTRA_PASSWORD= +MSHASTRA_SENDER_ID= + # Simulator API (for testing - sends real emails, prints SMS to terminal) # To enable simulator, set these URLs and enable the APIs: # EMAIL_API_ENABLED=True diff --git a/.gitignore b/.gitignore index 6b9a2de..93c6797 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,11 @@ postgres_data/ # Django migrations (exclude __init__.py) **/migrations/*.py !**/migrations/__init__.py + +# OpenCode skills +.opencode/skills/ + +# LibreOffice recalc scripts +scripts/office/ +scripts/recalc.py +Documents \ No newline at end of file diff --git a/.opencode/plans/mshastra-sms-integration.md b/.opencode/plans/mshastra-sms-integration.md new file mode 100644 index 0000000..23e64d7 --- /dev/null +++ b/.opencode/plans/mshastra-sms-integration.md @@ -0,0 +1,206 @@ +# Mshastra SMS Integration Plan + +## Files to modify: +1. `.env` - Add Mshastra credentials +2. `.env.example` - Add Mshastra credential placeholders +3. `config/settings/base.py` - Add Mshastra settings (~line 370) +4. `apps/notifications/services.py` - Add `_send_sms_mshastra()` and update routing +5. `apps/notifications/management/commands/test_sms.py` - New management command + +--- + +## 1. `.env` — Add after last line (after `SMS_API_KEY=simulator-test-key`): + +``` +# Mshastra SMS API +MSHASTRA_USERNAME= +MSHASTRA_PASSWORD= +MSHASTRA_SENDER_ID= +``` + +## 2. `.env.example` — Add before the `# Simulator API` comment block (around line 67): + +``` +# Mshastra SMS API +# Set SMS_PROVIDER=mshastra and fill in credentials to send real SMS via Mshastra +MSHASTRA_USERNAME= +MSHASTRA_PASSWORD= +MSHASTRA_SENDER_ID= +``` + +## 3. `config/settings/base.py` — Add after TWILIO settings block (after line 370): + +```python +# Mshastra SMS Configuration +MSHASTRA_USERNAME = env("MSHASTRA_USERNAME", default="") +MSHASTRA_PASSWORD = env("MSHASTRA_PASSWORD", default="") +MSHASTRA_SENDER_ID = env("MSHASTRA_SENDER_ID", default="") +``` + +## 4. `apps/notifications/services.py` — Changes: + +### 4a. Update `send_sms()` method (lines 55-72) +Replace the provider routing section to add `mshastra` check: + +Current code (lines 55-72): +```python + sms_config = settings.NOTIFICATION_CHANNELS.get("sms", {}) + provider = sms_config.get("provider", "console") + + log = NotificationLog.objects.create( + channel="sms", + recipient=phone, + message=message, + content_object=related_object, + provider=provider, + metadata=metadata or {}, + ) + + if provider == "twilio": + return NotificationService._send_sms_twilio(log, phone, message) + + logger.info(f"[SMS Console] To: {phone} | Message: {message}") + log.mark_sent() + return log +``` + +New code: +```python + sms_config = settings.NOTIFICATION_CHANNELS.get("sms", {}) + provider = sms_config.get("provider", "console") + + log = NotificationLog.objects.create( + channel="sms", + recipient=phone, + message=message, + content_object=related_object, + provider=provider, + metadata=metadata or {}, + ) + + if provider == "mshastra": + return NotificationService._send_sms_mshastra(log, phone, message) + + if provider == "twilio": + return NotificationService._send_sms_twilio(log, phone, message) + + logger.info(f"[SMS Console] To: {phone} | Message: {message}") + log.mark_sent() + return log +``` + +### 4b. Add `_send_sms_mshastra()` method after `_send_sms_twilio()` (after line 138): + +```python + @staticmethod + def _send_sms_mshastra(log, phone, message): + """ + Send SMS via Mshastra API. + + Requires: MSHASTRA_USERNAME, MSHASTRA_PASSWORD, and MSHASTRA_SENDER_ID. + API: https://mshastra.com/sendurl.aspx + """ + import requests + + username = settings.MSHASTRA_USERNAME + password = settings.MSHASTRA_PASSWORD + sender_id = settings.MSHASTRA_SENDER_ID + + if not username or not password: + logger.warning("Mshastra credentials not configured, falling back to console") + log.provider = "console" + log.save(update_fields=["provider"]) + logger.info(f"[SMS Console] To: {phone} | Message: {message}") + log.mark_sent() + return log + + try: + url = "https://mshastra.com/sendurl.aspx" + params = { + "user": username, + "pwd": password, + "senderid": sender_id, + "mobileno": phone, + "msgtext": message, + "priority": "High", + "CountryCode": "ALL", + } + + response = requests.get(url, params=params, timeout=30) + response_text = response.text.strip() + + log.provider_response = {"status_code": response.status_code, "response": response_text} + log.save(update_fields=["provider_response"]) + + if "Send Successful" in response_text: + log.mark_sent() + logger.info(f"SMS sent via Mshastra to {phone}: {response_text}") + else: + log.mark_failed(response_text) + logger.warning(f"Mshastra SMS failed for {phone}: {response_text}") + + return log + + except requests.exceptions.Timeout: + logger.error(f"Mshastra API timeout for {phone}") + log.mark_failed("Request timeout") + return log + + except requests.exceptions.ConnectionError: + logger.error(f"Mshastra API connection error for {phone}") + log.mark_failed("Connection error") + return log + + except Exception as e: + logger.error(f"Unexpected error sending SMS via Mshastra to {phone}: {e}", exc_info=True) + log.mark_failed(str(e)) + return log +``` + +## 5. Create `apps/notifications/management/commands/test_sms.py`: + +```python +""" +Management command to test SMS sending. + +Usage: + python manage.py test_sms 966501234567 + python manage.py test_sms 966501234567 --message "Custom test message" +""" + +from django.core.management.base import BaseCommand + +from apps.notifications.services import NotificationService + + +class Command(BaseCommand): + help = "Send a test SMS to a phone number" + + def add_arguments(self, parser): + parser.add_argument("phone", type=str, help="Phone number in international format (e.g. 966501234567)") + parser.add_argument("--message", type=str, default="Test SMS from PX360", help="Custom message text") + + def handle(self, *args, **options): + phone = options["phone"] + message = options["message"] + + self.stdout.write(f"Sending SMS to {phone}...") + self.stdout.write(f"Message: {message}") + self.stdout.write("") + + log = NotificationService.send_sms(phone, message) + + self.stdout.write(f"Status: {log.status}") + self.stdout.write(f"Provider: {log.provider}") + if log.error: + self.stdout.write(self.style.ERROR(f"Error: {log.error}")) + if log.provider_response: + self.stdout.write(f"Provider Response: {log.provider_response}") + + if log.status == "sent": + self.stdout.write(self.style.SUCCESS(f"SMS sent successfully to {phone}")) + elif log.status == "failed": + self.stdout.write(self.style.ERROR(f"SMS failed for {phone}")) + else: + self.stdout.write(self.style.WARNING(f"SMS status: {log.status} for {phone}")) +``` diff --git a/.~lock.Complaints Report - 2022.xlsx# b/.~lock.Complaints Report - 2022.xlsx# new file mode 100644 index 0000000..e444bc3 --- /dev/null +++ b/.~lock.Complaints Report - 2022.xlsx# @@ -0,0 +1 @@ +,ismail,ismail-Latitude-5500,25.03.2026 11:05,/home/ismail/.local/share/onlyoffice; \ No newline at end of file diff --git a/Complaints Report - 2022.xlsx b/Complaints Report - 2022.xlsx new file mode 100644 index 0000000..e575ea5 Binary files /dev/null and b/Complaints Report - 2022.xlsx differ diff --git a/Complaints Report - 2023.xlsx b/Complaints Report - 2023.xlsx new file mode 100644 index 0000000..5586eca Binary files /dev/null and b/Complaints Report - 2023.xlsx differ diff --git a/Complaints Report - 2024.xlsx b/Complaints Report - 2024.xlsx new file mode 100644 index 0000000..0ae75ae Binary files /dev/null and b/Complaints Report - 2024.xlsx differ diff --git a/Complaints Report - 2025.xlsx b/Complaints Report - 2025.xlsx new file mode 100644 index 0000000..62e2d31 Binary files /dev/null and b/Complaints Report - 2025.xlsx differ diff --git a/Mshastra_API (4).pdf b/Mshastra_API (4).pdf new file mode 100644 index 0000000..4bc84d5 Binary files /dev/null and b/Mshastra_API (4).pdf differ diff --git a/NOTIFICATION_INBOX_README.md b/NOTIFICATION_INBOX_README.md new file mode 100644 index 0000000..0d2d95d --- /dev/null +++ b/NOTIFICATION_INBOX_README.md @@ -0,0 +1,181 @@ +# Notification Inbox Implementation + +## Overview +A complete in-app notification inbox system has been implemented for PX360. Every email sent to a user now automatically creates a corresponding in-app notification. + +## Features Implemented + +### 1. Database Model: `UserNotification` +- **Location**: `apps/notifications/models.py` +- **Fields**: + - User (ForeignKey to User) + - Title & Message (Bilingual: EN/AR) + - Notification Type (complaint_assigned, sla_reminder, etc.) + - Read/Dismissed Status with timestamps + - Related Object (GenericForeignKey) + - Action URL for navigation + - Link to Email Log + +### 2. Automatic Notification Creation +- **Modified**: `apps/notifications/services.py` +- The `send_email()` method now automatically creates an in-app notification for every email sent +- Notifications are linked to the email log for tracking + +### 3. API Endpoints +- `GET /notifications/api/list/` - List notifications (with pagination) +- `GET /notifications/api/unread-count/` - Get unread count +- `GET /notifications/api/latest/` - Get latest 5 unread (for dropdown) +- `POST /notifications/api/mark-read//` - Mark single as read +- `POST /notifications/api/mark-all-read/` - Mark all as read +- `POST /notifications/api/dismiss//` - Dismiss single +- `POST /notifications/api/dismiss-all/` - Dismiss all + +### 4. Notification Inbox Page +- **URL**: `/notifications/inbox/` +- **Template**: `templates/notifications/inbox.html` +- Features: + - Filter tabs: All | Unread | Read + - List view with icons, titles, timestamps + - Mark as read / Dismiss actions + - Bulk actions (Mark all read, Dismiss all) + - Pagination (20 per page) + - Empty state + - Bilingual support (EN/AR) + +### 5. Topbar Integration +- **Modified**: `templates/layouts/partials/topbar.html` +- Real-time unread count badge +- Dynamic dropdown showing latest 5 notifications +- "View All" link to inbox +- Auto-refresh every 30 seconds + +### 6. Context Processor +- **Modified**: `apps/core/context_processors.py` +- Adds `notification_count` to all authenticated requests +- Shows unread count in topbar badge + +### 7. Admin Interface +- **Modified**: `apps/notifications/admin.py` +- Full CRUD for UserNotification model +- Filter by type, read status, dismissed status +- Bulk actions: Mark as read, Dismiss +- Search by user email, title, message + +### 8. Cleanup Command +- **Command**: `python manage.py cleanup_notifications` +- **Options**: + - `--days=30` - Configure retention period (default: 30) + - `--dry-run` - Preview what would be deleted +- Deletes notifications older than 30 days +- Run via cron daily + +## File Structure + +``` +apps/notifications/ +├── models.py # Added UserNotification model +├── views.py # Added inbox views and API endpoints +├── urls.py # Added notification inbox URLs +├── admin.py # Added UserNotificationAdmin +├── services.py # Modified send_email() to auto-create notifications +└── management/commands/ + └── cleanup_notifications.py # Cleanup old notifications + +templates/notifications/ +└── inbox.html # Notification inbox page + +templates/layouts/ +├── partials/topbar.html # Updated with dynamic notification dropdown +└── base.html # Added notification polling JavaScript + +apps/core/ +└── context_processors.py # Added notification_count to context +``` + +## Usage + +### For Users: +1. **View Notifications**: Click bell icon in topbar +2. **See All**: Click "View All" in dropdown or visit `/notifications/inbox/` +3. **Mark as Read**: Click notification or "Mark as read" button +4. **Dismiss**: Click X button to hide notification +5. **Navigate**: Click notification to go to related page + +### For Developers: + +#### Creating Notifications Manually: +```python +from apps.notifications.services import create_in_app_notification + +notification = create_in_app_notification( + user=user, + title="New Complaint Assigned", + message="You have been assigned a new complaint", + notification_type="complaint_assigned", + related_object=complaint, + action_url=f"/complaints/{complaint.id}/" +) +``` + +#### Auto-Created Notifications: +All emails sent via `NotificationService.send_email()` automatically create notifications: +```python +from apps.notifications.services import NotificationService + +NotificationService.send_email( + email=user.email, + subject="New Complaint", + message="A new complaint has been assigned to you", + notification_type="complaint_assigned", # This creates in-app notification + user=user # Optional: explicit user +) +``` + +## Configuration + +### Settings (add to settings.py): +```python +# Notification retention period (days) +NOTIFICATION_RETENTION_DAYS = 30 +``` + +### Cron Job (cleanup): +Add to crontab to run daily at 3 AM: +```bash +0 3 * * * cd /path/to/project && /path/to/venv/bin/python manage.py cleanup_notifications +``` + +## Testing Checklist + +- [ ] Send an email and check if notification appears +- [ ] Open notification inbox page +- [ ] Mark notification as read +- [ ] Dismiss notification +- [ ] Check topbar badge updates +- [ ] Click notification to navigate +- [ ] Test bilingual content (switch language) +- [ ] Run cleanup command with --dry-run +- [ ] Admin interface works +- [ ] API endpoints return correct data + +## Future Enhancements (Optional) + +1. **Real-time Updates**: Implement WebSocket for instant notifications +2. **Push Notifications**: Add browser push notification support +3. **Email Preferences**: Allow users to choose email vs in-app only +4. **Notification Settings**: Granular control per notification type +5. **Mobile App**: Push notifications for mobile app + +## Summary + +✅ **Model**: UserNotification with all required fields +✅ **Auto-creation**: Every email creates in-app notification +✅ **Inbox Page**: Full-featured notification center at `/notifications/inbox/` +✅ **Topbar**: Dynamic dropdown with real-time count +✅ **API**: Complete REST API for all operations +✅ **Admin**: Full Django admin integration +✅ **Cleanup**: Automated deletion of old notifications (30 days) +✅ **Bilingual**: EN/AR support throughout +✅ **Context**: notification_count available in all templates + +The notification inbox is now fully functional and ready for use! diff --git a/PatientWorkFlow (1).docx b/PatientWorkFlow (1).docx new file mode 100644 index 0000000..ec5304c Binary files /dev/null and b/PatientWorkFlow (1).docx differ diff --git a/SLA_MANAGEMENT_FEATURE.md b/SLA_MANAGEMENT_FEATURE.md new file mode 100644 index 0000000..2d25977 --- /dev/null +++ b/SLA_MANAGEMENT_FEATURE.md @@ -0,0 +1,148 @@ +# Complaint SLA Management Feature + +## Overview +A dedicated PX Admin-only interface for managing complaint SLA (Service Level Agreement) configurations with automatic hospital selection from session. + +## Features + +### 1. PX Admin Only Access +- Protected by `@px_admin_required` decorator +- Automatically uses hospital from session (`selected_hospital_id`) +- Redirects to hospital selection if no hospital is selected + +### 2. Hospital Context +- Hospital is **automatically set** from the first selection after login +- Stored in session as `selected_hospital_id` +- No need to manually select hospital for each configuration + +### 3. SLA Configuration Types + +#### Source-Based SLAs +- Configure SLAs based on complaint source (MOH, CCHI, Patient, etc.) +- Takes precedence over severity-based configs +- Example: MOH complaints = 24 hours, CCHI = 48 hours + +#### Severity/Priority-Based SLAs +- Configure SLAs based on severity and priority levels +- Used when no source-based config exists +- Example: Critical/Urgent = 12 hours, Low/Low = 72 hours + +### 4. Timing Configuration + +#### SLA Deadline +- Total hours from complaint creation until deadline +- Example: 24, 48, 72 hours + +#### Reminder Timing (Modern) +- **First Reminder**: X hours after complaint creation +- **Second Reminder**: X hours after complaint creation +- Set to 0 to use legacy timing + +#### Reminder Timing (Legacy) +- **First Reminder**: X hours before deadline +- **Second Reminder**: X hours before deadline (with enable checkbox) + +#### Escalation +- X hours after creation to auto-escalate +- Set to 0 to use standard overdue logic + +## URLs + +| URL | View | Description | +|-----|------|-------------| +| `/complaints/settings/sla-management/` | `sla_management` | Main SLA management page | +| `/complaints/settings/sla-management/new/` | `sla_management_create` | Create new SLA config | +| `/complaints/settings/sla-management//edit/` | `sla_management_edit` | Edit existing config | +| `/complaints/settings/sla-management//toggle/` | `sla_management_toggle` | Toggle active status | + +## Files Created/Modified + +### New Files +1. `/apps/complaints/management/commands/test_sla_reminders.py` - Test command +2. `/templates/complaints/sla_management.html` - Main management page +3. `/templates/complaints/sla_management_form.html` - Create/Edit form +4. `/templates/complaints/partials/severity_badge.html` - Severity badge partial +5. `/templates/complaints/partials/priority_badge.html` - Priority badge partial + +### Modified Files +1. `/apps/complaints/ui_views.py` - Added 4 new views +2. `/apps/complaints/urls.py` - Added 4 new URL routes +3. `/templates/config/dashboard.html` - Added navigation card + +## Usage + +### For PX Admins + +1. **Login** as PX Admin +2. **Select Hospital** (first time after login) +3. **Navigate to** System Configuration → Complaint SLA + - Or directly: `/complaints/settings/sla-management/` +4. **Configure SLAs**: + - Click "Add SLA Configuration" + - Choose Source (for source-based) OR Severity/Priority + - Set SLA hours, reminders, escalation + - Save + +### Example Configuration + +**MOH Complaints (Source-Based)**: +- Source: Ministry of Health +- SLA Hours: 24 +- First Reminder: 12 hours after creation +- Second Reminder: 18 hours after creation +- Escalation: 24 hours after creation + +**Critical/Urgent Complaints (Severity-Based)**: +- Severity: Critical +- Priority: Urgent +- SLA Hours: 12 +- First Reminder: 6 hours after creation +- Second Reminder: 9 hours after creation +- Escalation: 12 hours after creation + +## Testing + +Use the management command to test SLA reminders: + +```bash +# Create test complaints and run SLA reminder task +python manage.py test_sla_reminders --run-task + +# Dry run (preview only) +python manage.py test_sla_reminders --dry-run + +# Test specific scenario +python manage.py test_sla_reminders --scenario assigned --complaint-count 3 + +# Clean up test data +python manage.py test_sla_reminders --cleanup +``` + +## SLA Priority Order + +1. **Source-based config** (MOH, CCHI, Internal) +2. **Severity/Priority-based config** +3. **System defaults** (from settings) + +## Key Benefits + +✅ **Hospital Auto-Selection**: No need to manually select hospital each time +✅ **PX Admin Only**: Secure, role-based access +✅ **Visual Interface**: Clean, modern UI with badges and stats +✅ **Flexible Timing**: Support for both modern (after creation) and legacy (before deadline) timing +✅ **Toggle Status**: Easily enable/disable configs without deleting +✅ **Audit Logging**: All changes logged for compliance + +## Session Flow + +``` +1. PX Admin logs in + ↓ +2. Redirected to hospital selection page + ↓ +3. Selects hospital → stored in session['selected_hospital_id'] + ↓ +4. All SLA configs use this hospital automatically + ↓ +5. Can switch hospitals anytime from sidebar +``` diff --git a/apps/accounts/ui_views.py b/apps/accounts/ui_views.py index ab110b1..3fd204b 100644 --- a/apps/accounts/ui_views.py +++ b/apps/accounts/ui_views.py @@ -1183,7 +1183,7 @@ def acknowledgement_content_list(request): return redirect("/dashboard/") # Get all content - content_list = AcknowledgementContent.objects.all().order_by("role", "order") + content_list = AcknowledgementContent.objects.select_related("category").order_by("category", "order") context = { "page_title": "Acknowledgement Content", @@ -1202,10 +1202,14 @@ def acknowledgement_checklist_list(request): return redirect("/dashboard/") # Get all checklist items - checklist_items = AcknowledgementChecklistItem.objects.select_related("content").order_by("role", "order") + checklist_items = AcknowledgementChecklistItem.objects.select_related("content", "category").order_by( + "category", "order" + ) # Get all content for the modal dropdown - content_list = AcknowledgementContent.objects.filter(is_active=True).order_by("role", "order") + content_list = ( + AcknowledgementContent.objects.filter(is_active=True).select_related("category").order_by("category", "order") + ) context = { "page_title": "Acknowledgement Checklist Items", diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 578baaf..66b96c8 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -9,6 +9,9 @@ from apps.accounts import views as account_views_main from apps.accounts.views import user_settings, CustomTokenObtainPairView from .ui_views import ( + acknowledgement_checklist_list, + acknowledgement_content_list, + acknowledgement_dashboard, bulk_deactivate_users, bulk_invite_users, bulk_resend_invitations, @@ -29,46 +32,55 @@ from .ui_views import ( provisional_user_progress, ) -app_name = 'accounts' +app_name = "accounts" router = DefaultRouter() -router.register(r'users', account_views_main.UserViewSet, basename='user') -router.register(r'roles', account_views_main.RoleViewSet, basename='role') +router.register(r"users", account_views_main.UserViewSet, basename="user") +router.register(r"roles", account_views_main.RoleViewSet, basename="role") +router.register( + r"api/checklist-items", + account_views_main.AcknowledgementChecklistItemViewSet, + basename="checklist-items", +) urlpatterns = [ # Simple Acknowledgement URLs (simplified system) - path('acknowledgements/', include('apps.accounts.simple_acknowledgement_urls')), - + path("acknowledgements/", include("apps.accounts.simple_acknowledgement_urls")), # UI Authentication URLs - path('login/', login_view, name='login'), - path('logout/', logout_view, name='logout'), - path('settings/', user_settings, name='settings'), - path('password/reset/', password_reset_view, name='password_reset'), - path('password/reset/confirm///', CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'), - path('password/change/', change_password_view, name='password_change'), - + path("login/", login_view, name="login"), + path("logout/", logout_view, name="logout"), + path("settings/", user_settings, name="settings"), + path("password/reset/", password_reset_view, name="password_reset"), + path( + "password/reset/confirm///", + CustomPasswordResetConfirmView.as_view(), + name="password_reset_confirm", + ), + path("password/change/", change_password_view, name="password_change"), # JWT Authentication - path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), - path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), - + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), # User and Role endpoints - path('', include(router.urls)), - + path("", include(router.urls)), # Onboarding Wizard UI - path('onboarding/activate//', onboarding_activate, name='onboarding-activate'), - path('onboarding/welcome/', onboarding_welcome, name='onboarding-welcome'), - path('onboarding/wizard/step//', onboarding_step_content, name='onboarding-step-content'), - path('onboarding/wizard/checklist/', onboarding_step_checklist, name='onboarding-step-checklist'), - path('onboarding/wizard/activation/', onboarding_step_activation, name='onboarding-step-activation'), - path('onboarding/complete/', onboarding_complete, name='onboarding-complete'), - + path("onboarding/activate//", onboarding_activate, name="onboarding-activate"), + path("onboarding/welcome/", onboarding_welcome, name="onboarding-welcome"), + path("onboarding/wizard/step//", onboarding_step_content, name="onboarding-step-content"), + path("onboarding/wizard/checklist/", onboarding_step_checklist, name="onboarding-step-checklist"), + path("onboarding/wizard/activation/", onboarding_step_activation, name="onboarding-step-activation"), + path("onboarding/complete/", onboarding_complete, name="onboarding-complete"), # Provisional User Management - path('onboarding/provisional/', provisional_user_list, name='provisional-user-list'), - path('onboarding/provisional//progress/', provisional_user_progress, name='provisional-user-progress'), - path('onboarding/bulk-invite/', bulk_invite_users, name='bulk-invite-users'), - path('onboarding/bulk-resend/', bulk_resend_invitations, name='bulk-resend-invitations'), - path('onboarding/bulk-deactivate/', bulk_deactivate_users, name='bulk-deactivate-users'), - path('onboarding/export/users/', export_provisional_users, name='export-provisional-users'), - path('onboarding/preview/', preview_wizard_as_role, name='preview-wizard'), - path('onboarding/preview//', preview_wizard_as_role, name='preview-wizard-role'), + path("onboarding/provisional/", provisional_user_list, name="provisional-user-list"), + path( + "onboarding/provisional//progress/", provisional_user_progress, name="provisional-user-progress" + ), + path("onboarding/bulk-invite/", bulk_invite_users, name="bulk-invite-users"), + path("onboarding/bulk-resend/", bulk_resend_invitations, name="bulk-resend-invitations"), + path("onboarding/bulk-deactivate/", bulk_deactivate_users, name="bulk-deactivate-users"), + path("onboarding/export/users/", export_provisional_users, name="export-provisional-users"), + path("onboarding/preview/", preview_wizard_as_role, name="preview-wizard"), + path("onboarding/preview//", preview_wizard_as_role, name="preview-wizard-role"), + path("onboarding/dashboard/", acknowledgement_dashboard, name="onboarding-dashboard"), + path("onboarding/content/", acknowledgement_content_list, name="onboarding-content-list"), + path("onboarding/checklist/", acknowledgement_checklist_list, name="onboarding-checklist-list"), ] diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 0ef6b9f..65d3277 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -398,10 +398,10 @@ class AcknowledgementChecklistItemViewSet(viewsets.ModelViewSet): queryset = AcknowledgementChecklistItem.objects.all() serializer_class = AcknowledgementChecklistItemSerializer permission_classes = [CanManageAcknowledgementContent] - filterset_fields = ["role", "content", "is_required", "is_active"] + filterset_fields = ["category", "content", "is_required", "is_active"] search_fields = ["code", "text_en", "text_ar", "description_en", "description_ar"] - ordering_fields = ["role", "order"] - ordering = ["role", "order"] + ordering_fields = ["category", "order"] + ordering = ["category", "order"] def get_queryset(self): return super().get_queryset().select_related("content") diff --git a/apps/analytics/kpi_models.py b/apps/analytics/kpi_models.py index 7491802..8021a04 100644 --- a/apps/analytics/kpi_models.py +++ b/apps/analytics/kpi_models.py @@ -3,13 +3,14 @@ KPI Report Models - Monthly automated reports based on MOH requirements This module implements KPI reports that match the Excel-style templates: - 72H Resolution Rate (MOH-2) -- Patient Experience Score (MOH-1) +- Patient Experience Score (MOH-1) - Overall Satisfaction with Resolution (MOH-3) - N-PAD-001 Resolution Rate - Response Rate (Dep-KPI-4) - Activation Within 2 Hours (KPI-6) - Unactivated Filled Complaints Rate (KPI-7) """ + from django.db import models from django.utils.translation import gettext_lazy as _ @@ -18,6 +19,7 @@ from apps.core.models import TimeStampedModel, UUIDModel class KPIReportType(models.TextChoices): """KPI report types matching MOH and internal requirements""" + RESOLUTION_72H = "resolution_72h", _("72-Hour Resolution Rate (MOH-2)") PATIENT_EXPERIENCE = "patient_experience", _("Patient Experience Score (MOH-1)") SATISFACTION_RESOLUTION = "satisfaction_resolution", _("Overall Satisfaction with Resolution (MOH-3)") @@ -29,6 +31,7 @@ class KPIReportType(models.TextChoices): class KPIReportStatus(models.TextChoices): """Status of KPI report generation""" + PENDING = "pending", _("Pending") GENERATING = "generating", _("Generating") COMPLETED = "completed", _("Completed") @@ -38,38 +41,31 @@ class KPIReportStatus(models.TextChoices): class KPIReport(UUIDModel, TimeStampedModel): """ KPI Report - Monthly automated report for a specific KPI type - + Each report represents one month of data for a specific KPI, matching the Excel-style table format from the reference templates. """ + report_type = models.CharField( - max_length=50, - choices=KPIReportType.choices, - db_index=True, - help_text=_("Type of KPI report") + max_length=50, choices=KPIReportType.choices, db_index=True, help_text=_("Type of KPI report") ) - + # Organization scope hospital = models.ForeignKey( "organizations.Hospital", on_delete=models.CASCADE, related_name="kpi_reports", - help_text=_("Hospital this report belongs to") + help_text=_("Hospital this report belongs to"), ) - + # Reporting period year = models.IntegerField(db_index=True) month = models.IntegerField(db_index=True) - + # Report metadata - report_date = models.DateField( - help_text=_("Date the report was generated") - ) + report_date = models.DateField(help_text=_("Date the report was generated")) status = models.CharField( - max_length=20, - choices=KPIReportStatus.choices, - default=KPIReportStatus.PENDING, - db_index=True + max_length=20, choices=KPIReportStatus.choices, default=KPIReportStatus.PENDING, db_index=True ) generated_by = models.ForeignKey( "accounts.User", @@ -77,28 +73,27 @@ class KPIReport(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="generated_kpi_reports", - help_text=_("User who generated the report (null for automated)") + help_text=_("User who generated the report (null for automated)"), ) generated_at = models.DateTimeField(null=True, blank=True) - + # Report configuration metadata target_percentage = models.DecimalField( + max_digits=5, decimal_places=2, default=95.00, help_text=_("Target percentage for this KPI") + ) + threshold_percentage = models.DecimalField( max_digits=5, decimal_places=2, - default=95.00, - help_text=_("Target percentage for this KPI") + default=90.00, + help_text=_("Threshold (minimum acceptable) percentage for this KPI"), ) - + # Report metadata (category, type, risk, etc.) category = models.CharField( - max_length=50, - default="Organizational", - help_text=_("Report category (e.g., Organizational, Clinical)") + max_length=50, default="Organizational", help_text=_("Report category (e.g., Organizational, Clinical)") ) kpi_type = models.CharField( - max_length=50, - default="Outcome", - help_text=_("KPI type (e.g., Outcome, Process, Structure)") + max_length=50, default="Outcome", help_text=_("KPI type (e.g., Outcome, Process, Structure)") ) risk_level = models.CharField( max_length=20, @@ -108,70 +103,41 @@ class KPIReport(UUIDModel, TimeStampedModel): ("Medium", "Medium"), ("Low", "Low"), ], - help_text=_("Risk level for this KPI") + help_text=_("Risk level for this KPI"), ) data_collection_method = models.CharField( - max_length=50, - default="Retrospective", - help_text=_("Data collection method") + max_length=50, default="Retrospective", help_text=_("Data collection method") ) data_collection_frequency = models.CharField( - max_length=50, - default="Monthly", - help_text=_("How often data is collected") + max_length=50, default="Monthly", help_text=_("How often data is collected") ) reporting_frequency = models.CharField( - max_length=50, - default="Monthly", - help_text=_("How often report is generated") + max_length=50, default="Monthly", help_text=_("How often report is generated") ) dimension = models.CharField( - max_length=50, - default="Efficiency", - help_text=_("KPI dimension (e.g., Efficiency, Quality, Safety)") + max_length=50, default="Efficiency", help_text=_("KPI dimension (e.g., Efficiency, Quality, Safety)") ) - collector_name = models.CharField( - max_length=200, - blank=True, - help_text=_("Name of data collector") - ) - analyzer_name = models.CharField( - max_length=200, - blank=True, - help_text=_("Name of data analyzer") - ) - + collector_name = models.CharField(max_length=200, blank=True, help_text=_("Name of data collector")) + analyzer_name = models.CharField(max_length=200, blank=True, help_text=_("Name of data analyzer")) + # Summary metrics - total_numerator = models.IntegerField( - default=0, - help_text=_("Total count of successful outcomes") - ) - total_denominator = models.IntegerField( - default=0, - help_text=_("Total count of all cases") - ) + total_numerator = models.IntegerField(default=0, help_text=_("Total count of successful outcomes")) + total_denominator = models.IntegerField(default=0, help_text=_("Total count of all cases")) overall_result = models.DecimalField( - max_digits=6, - decimal_places=2, - default=0.00, - help_text=_("Overall percentage result") + max_digits=6, decimal_places=2, default=0.00, help_text=_("Overall percentage result") ) - + # Error tracking error_message = models.TextField(blank=True) - + # AI-generated analysis ai_analysis = models.JSONField( - null=True, - blank=True, - help_text=_("AI-generated analysis and recommendations for this report") + null=True, blank=True, help_text=_("AI-generated analysis and recommendations for this report") ) ai_analysis_generated_at = models.DateTimeField( - null=True, - blank=True, - help_text=_("When the AI analysis was generated") + null=True, blank=True, help_text=_("When the AI analysis was generated") ) - + class Meta: ordering = ["-year", "-month", "report_type"] unique_together = [["report_type", "hospital", "year", "month"]] @@ -182,19 +148,30 @@ class KPIReport(UUIDModel, TimeStampedModel): ] verbose_name = "KPI Report" verbose_name_plural = "KPI Reports" - + def __str__(self): return f"{self.get_report_type_display()} - {self.year}/{self.month:02d} - {self.hospital.name}" - + @property def report_period_display(self): """Get human-readable report period""" month_names = [ - "", "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December" + "", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", ] return f"{month_names[self.month]} {self.year}" - + @property def kpi_id(self): """Get KPI ID based on report type""" @@ -208,7 +185,7 @@ class KPIReport(UUIDModel, TimeStampedModel): KPIReportType.UNACTIVATED: "KPI-7", } return mapping.get(self.report_type, "KPI") - + @property def indicator_title(self): """Get indicator title based on report type""" @@ -222,7 +199,7 @@ class KPIReport(UUIDModel, TimeStampedModel): KPIReportType.UNACTIVATED: "Unactivated Filled Complaints Rate", } return titles.get(self.report_type, "KPI Report") - + @property def numerator_label(self): """Get label for numerator based on report type""" @@ -236,7 +213,7 @@ class KPIReport(UUIDModel, TimeStampedModel): KPIReportType.UNACTIVATED: "Unactivated Complaints", } return labels.get(self.report_type, "Numerator") - + @property def denominator_label(self): """Get label for denominator based on report type""" @@ -255,51 +232,29 @@ class KPIReport(UUIDModel, TimeStampedModel): class KPIReportMonthlyData(UUIDModel, TimeStampedModel): """ Monthly breakdown data for a KPI report - + Stores the Jan-Dec + TOTAL values shown in the Excel-style table. This allows for trend analysis and historical comparison. """ - kpi_report = models.ForeignKey( - KPIReport, - on_delete=models.CASCADE, - related_name="monthly_data" - ) - + + kpi_report = models.ForeignKey(KPIReport, on_delete=models.CASCADE, related_name="monthly_data") + # Month (1-12) - 0 represents the TOTAL row - month = models.IntegerField( - db_index=True, - help_text=_("Month number (1-12), 0 for TOTAL") - ) - + month = models.IntegerField(db_index=True, help_text=_("Month number (1-12), 0 for TOTAL")) + # Values for this month - numerator = models.IntegerField( - default=0, - help_text=_("Count of successful outcomes") - ) - denominator = models.IntegerField( - default=0, - help_text=_("Count of all cases") - ) - percentage = models.DecimalField( - max_digits=6, - decimal_places=2, - default=0.00, - help_text=_("Calculated percentage") - ) - + numerator = models.IntegerField(default=0, help_text=_("Count of successful outcomes")) + denominator = models.IntegerField(default=0, help_text=_("Count of all cases")) + percentage = models.DecimalField(max_digits=6, decimal_places=2, default=0.00, help_text=_("Calculated percentage")) + # Status indicators - is_below_target = models.BooleanField( - default=False, - help_text=_("Whether this month is below target") - ) - + is_below_target = models.BooleanField(default=False, help_text=_("Whether this month is below target")) + # Additional metadata for this month details = models.JSONField( - default=dict, - blank=True, - help_text=_("Additional breakdown data (e.g., by source, department)") + default=dict, blank=True, help_text=_("Additional breakdown data (e.g., by source, department)") ) - + class Meta: ordering = ["month"] unique_together = [["kpi_report", "month"]] @@ -308,14 +263,15 @@ class KPIReportMonthlyData(UUIDModel, TimeStampedModel): ] verbose_name = "KPI Monthly Data" verbose_name_plural = "KPI Monthly Data" - + def __str__(self): - month_name = "TOTAL" if self.month == 0 else [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" - ][self.month - 1] + month_name = ( + "TOTAL" + if self.month == 0 + else ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][self.month - 1] + ) return f"{self.kpi_report} - {month_name}: {self.percentage}%" - + def calculate_percentage(self): """Calculate percentage from numerator and denominator""" if self.denominator > 0: @@ -328,16 +284,13 @@ class KPIReportMonthlyData(UUIDModel, TimeStampedModel): class KPIReportDepartmentBreakdown(UUIDModel, TimeStampedModel): """ Department-level breakdown for KPI reports - + Stores metrics for each department to show in the department grid section of the report (Medical, Nursing, Admin, Support Services). """ - kpi_report = models.ForeignKey( - KPIReport, - on_delete=models.CASCADE, - related_name="department_breakdowns" - ) - + + kpi_report = models.ForeignKey(KPIReport, on_delete=models.CASCADE, related_name="department_breakdowns") + department_category = models.CharField( max_length=50, choices=[ @@ -346,59 +299,81 @@ class KPIReportDepartmentBreakdown(UUIDModel, TimeStampedModel): ("admin", "Non-Medical / Admin"), ("support", "Support Services"), ], - help_text=_("Category of department") + help_text=_("Category of department"), ) - + # Department-specific metrics complaint_count = models.IntegerField(default=0) resolved_count = models.IntegerField(default=0) - avg_resolution_days = models.DecimalField( - max_digits=5, - decimal_places=2, - null=True, - blank=True - ) - + avg_resolution_days = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) + # Top complaints/areas (stored as text for display) - top_areas = models.TextField( - blank=True, - help_text=_("Top complaint areas or notes (newline-separated)") - ) - + top_areas = models.TextField(blank=True, help_text=_("Top complaint areas or notes (newline-separated)")) + # JSON field for flexible department-specific data details = models.JSONField(default=dict, blank=True) - + class Meta: ordering = ["department_category"] unique_together = [["kpi_report", "department_category"]] verbose_name = "KPI Department Breakdown" verbose_name_plural = "KPI Department Breakdowns" - + def __str__(self): return f"{self.kpi_report} - {self.get_department_category_display()}" +class KPIReportLocationBreakdown(UUIDModel, TimeStampedModel): + """ + Location-level breakdown for KPI reports + + Stores complaint distribution by location type + (In-Patient, Out-Patient, ER). + """ + + kpi_report = models.ForeignKey(KPIReport, on_delete=models.CASCADE, related_name="location_breakdowns") + + location_type = models.CharField( + max_length=50, + choices=[ + ("In-Patient", "In-Patient"), + ("Out-Patient", "Out-Patient"), + ("ER", "ER"), + ], + help_text=_("Location type category"), + ) + + complaint_count = models.IntegerField(default=0) + percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0.00) + + class Meta: + ordering = ["location_type"] + unique_together = [["kpi_report", "location_type"]] + verbose_name = "KPI Location Breakdown" + verbose_name_plural = "KPI Location Breakdowns" + + def __str__(self): + return f"{self.kpi_report} - {self.location_type}: {self.percentage}%" + + class KPIReportSourceBreakdown(UUIDModel, TimeStampedModel): """ Complaint source breakdown for KPI reports - + Stores percentage distribution of complaints by source (Patient, Family, Staff, MOH, CHI, etc.) """ - kpi_report = models.ForeignKey( - KPIReport, - on_delete=models.CASCADE, - related_name="source_breakdowns" - ) - + + kpi_report = models.ForeignKey(KPIReport, on_delete=models.CASCADE, related_name="source_breakdowns") + source_name = models.CharField(max_length=100) complaint_count = models.IntegerField(default=0) percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0.00) - + class Meta: ordering = ["-complaint_count"] verbose_name = "KPI Source Breakdown" verbose_name_plural = "KPI Source Breakdowns" - + def __str__(self): return f"{self.kpi_report} - {self.source_name}: {self.percentage}%" diff --git a/apps/analytics/kpi_service.py b/apps/analytics/kpi_service.py index 3efaa8f..f9d9bd7 100644 --- a/apps/analytics/kpi_service.py +++ b/apps/analytics/kpi_service.py @@ -4,6 +4,7 @@ KPI Report Calculation Service This service calculates KPI metrics for monthly reports based on the complaint and survey data in the system. """ + import logging from datetime import datetime, timedelta from decimal import Decimal @@ -19,19 +20,83 @@ from apps.surveys.models import SurveyInstance, SurveyStatus, SurveyTemplate from .kpi_models import ( KPIReport, KPIReportDepartmentBreakdown, + KPIReportLocationBreakdown, KPIReportMonthlyData, KPIReportSourceBreakdown, KPIReportStatus, KPIReportType, ) +DEPARTMENT_CATEGORY_KEYWORDS = { + "medical": [ + "medical", + "surgery", + "cardiology", + "orthopedics", + "pediatrics", + "obstetrics", + "gynecology", + "er", + "emergency", + "lab", + "icu", + "clinic", + "ward", + "physiotherapy", + "psychiatric", + "ophthalmology", + "pharmacy", + "radiology", + "pathology", + "anesthesia", + "ent", + "dermatology", + "urology", + "neurology", + "oncology", + "nursery", + ], + "nursing": [ + "nursing", + "nurse", + "iv medication room nursing", + "long term nursing", + "vaccination room nursing", + "pediatric inpatient", + ], + "admin": [ + "administration", + "admin", + "reception", + "manager", + "approval", + "report", + "finance", + "it", + "hr", + "medical reports", + "appointments", + "on-duty", + "opd reception", + "outpatient reception", + ], + "support": [ + "housekeeping", + "maintenance", + "security", + "cafeteria", + "transport", + "support", + ], +} + logger = logging.getLogger(__name__) class KPICalculationService: """ Service for calculating KPI report metrics - + Handles the complex calculations for each KPI type: - 72H Resolution Rate - Patient Experience Score @@ -40,26 +105,19 @@ class KPICalculationService: - Activation Time - Unactivated Rate """ - + @classmethod - def generate_monthly_report( - cls, - report_type: str, - hospital, - year: int, - month: int, - generated_by=None - ) -> KPIReport: + def generate_monthly_report(cls, report_type: str, hospital, year: int, month: int, generated_by=None) -> KPIReport: """ Generate a complete monthly KPI report - + Args: report_type: Type of KPI report (from KPIReportType) hospital: Hospital instance year: Report year month: Report month (1-12) generated_by: User who generated the report (optional) - + Returns: KPIReport instance """ @@ -73,17 +131,42 @@ class KPICalculationService: "report_date": timezone.now().date(), "status": KPIReportStatus.PENDING, "generated_by": generated_by, - } + "target_percentage": ( + 0.00 + if report_type == KPIReportType.UNACTIVATED + else 85.00 + if report_type == KPIReportType.PATIENT_EXPERIENCE + else 80.00 + if report_type == KPIReportType.RESPONSE_RATE + else 95.00 + ), + "threshold_percentage": ( + 5.00 + if report_type == KPIReportType.UNACTIVATED + else 78.00 + if report_type == KPIReportType.PATIENT_EXPERIENCE + else 70.00 + if report_type == KPIReportType.RESPONSE_RATE + else 90.00 + ), + "threshold_percentage": ( + 78.00 + if report_type == KPIReportType.PATIENT_EXPERIENCE + else 70.00 + if report_type == KPIReportType.RESPONSE_RATE + else 90.00 + ), + }, ) - + if not created and report.status == KPIReportStatus.COMPLETED: # Report already exists and is complete - return it return report - + # Update status to generating report.status = KPIReportStatus.GENERATING report.save() - + try: # Calculate based on report type if report_type == KPIReportType.RESOLUTION_72H: @@ -100,20 +183,21 @@ class KPICalculationService: cls._calculate_activation_2h(report) elif report_type == KPIReportType.UNACTIVATED: cls._calculate_unactivated(report) - + # Mark as completed report.status = KPIReportStatus.COMPLETED report.generated_at = timezone.now() report.save() - + # Generate AI analysis for supported report types supported_types = [ KPIReportType.RESOLUTION_72H, + KPIReportType.PATIENT_EXPERIENCE, KPIReportType.N_PAD_001, KPIReportType.SATISFACTION_RESOLUTION, KPIReportType.RESPONSE_RATE, KPIReportType.ACTIVATION_2H, - KPIReportType.UNACTIVATED + KPIReportType.UNACTIVATED, ] if report_type in supported_types: try: @@ -121,18 +205,18 @@ class KPICalculationService: logger.info(f"AI analysis generated for KPI report {report.id}") except Exception as ai_error: logger.warning(f"Failed to generate AI analysis for report {report.id}: {ai_error}") - + logger.info(f"KPI Report {report.id} generated successfully") - + except Exception as e: logger.exception(f"Error generating KPI report {report.id}") report.status = KPIReportStatus.FAILED report.error_message = str(e) report.save() raise - + return report - + @classmethod def _calculate_72h_resolution(cls, report: KPIReport): """Calculate 72-Hour Resolution Rate (MOH-2)""" @@ -142,32 +226,32 @@ class KPICalculationService: end_date = datetime(report.year + 1, 1, 1) else: end_date = datetime(report.year, report.month + 1, 1) - + # Get all months data for YTD (year to date) year_start = datetime(report.year, 1, 1) - + # Calculate for each month total_numerator = 0 total_denominator = 0 - + for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) - + # Get complaints for this month complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, - complaint_type="complaint" # Only actual complaints + complaint_type="complaint", # Only actual complaints ) - + # Count total complaints denominator = complaints.count() - + # Count resolved within 72 hours numerator = 0 for complaint in complaints: @@ -175,7 +259,7 @@ class KPICalculationService: resolution_time = complaint.resolved_at - complaint.created_at if resolution_time.total_seconds() <= 72 * 3600: # 72 hours numerator += 1 - + # Create or update monthly data monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, @@ -183,40 +267,40 @@ class KPICalculationService: defaults={ "numerator": numerator, "denominator": denominator, - } + }, ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage - + # Store source breakdown in details source_data = cls._get_source_breakdown(complaints) monthly_data.details = {"source_breakdown": source_data} monthly_data.save() - + total_numerator += numerator total_denominator += denominator - + # Update report totals report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() - + # Create source breakdown for pie chart all_complaints = Complaint.objects.filter( - hospital=report.hospital, - created_at__gte=year_start, - created_at__lt=end_date, - complaint_type="complaint" + hospital=report.hospital, created_at__gte=year_start, created_at__lt=end_date, complaint_type="complaint" ) cls._create_source_breakdowns(report, all_complaints) - + # Create department breakdown cls._create_department_breakdown(report, all_complaints) - + + # Create location breakdown + cls._create_location_breakdowns(report, all_complaints) + @classmethod def _calculate_patient_experience(cls, report: KPIReport): """Calculate Patient Experience Score (MOH-1)""" @@ -227,243 +311,276 @@ class KPICalculationService: end_date = datetime(report.year + 1, 1, 1) else: end_date = datetime(report.year, report.month + 1, 1) - + total_numerator = 0 total_denominator = 0 - + for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) - + # Get completed surveys for patient experience surveys = SurveyInstance.objects.filter( survey_template__hospital=report.hospital, status=SurveyStatus.COMPLETED, completed_at__gte=month_start, completed_at__lt=month_end, - survey_template__survey_type__in=["stage", "general"] + survey_template__survey_type__in=["stage", "general"], ) - + denominator = surveys.count() - + # Count positive responses (score >= 4 out of 5) numerator = surveys.filter(total_score__gte=4).count() - + monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, - } + }, ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage - + # Store average score - avg_score = surveys.aggregate(avg=Avg('total_score'))['avg'] or 0 - monthly_data.details = {"avg_score": round(avg_score, 2)} + avg_score = surveys.aggregate(avg=Avg("total_score"))["avg"] or 0 + monthly_data.details = {"avg_score": float(round(avg_score, 2))} monthly_data.save() - + total_numerator += numerator total_denominator += denominator - + report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() - + @classmethod def _calculate_satisfaction_resolution(cls, report: KPIReport): """Calculate Overall Satisfaction with Resolution (MOH-3)""" year_start = datetime(report.year, 1, 1) - + total_numerator = 0 total_denominator = 0 - + for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) - + # Get complaint resolution surveys surveys = SurveyInstance.objects.filter( survey_template__hospital=report.hospital, status=SurveyStatus.COMPLETED, completed_at__gte=month_start, completed_at__lt=month_end, - survey_template__survey_type="complaint_resolution" + survey_template__survey_type="complaint_resolution", ) - + denominator = surveys.count() # Satisfied = score >= 4 numerator = surveys.filter(total_score__gte=4).count() - + monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, - } + }, ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage monthly_data.save() - + total_numerator += numerator total_denominator += denominator - + report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() - + + all_complaints = Complaint.objects.filter( + hospital=report.hospital, + created_at__gte=year_start, + created_at__lt=( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ), + complaint_type="complaint", + ) + cls._create_source_breakdowns(report, all_complaints) + cls._create_department_breakdown(report, all_complaints) + cls._create_location_breakdowns(report, all_complaints) + @classmethod def _calculate_n_pad_001(cls, report: KPIReport): """Calculate N-PAD-001 Resolution Rate""" year_start = datetime(report.year, 1, 1) - + if report.month == 12: + year_end = datetime(report.year + 1, 1, 1) + else: + year_end = datetime(report.year, report.month + 1, 1) + total_numerator = 0 total_denominator = 0 - + for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) - - # Get complaints + complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, - complaint_type="complaint" + complaint_type="complaint", ) - + denominator = complaints.count() - # Resolved includes closed and resolved statuses - numerator = complaints.filter( - status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED] - ).count() - + numerator = complaints.filter(status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED]).count() + monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, - } + }, ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage monthly_data.save() - + total_numerator += numerator total_denominator += denominator - + report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() - + + all_complaints = Complaint.objects.filter( + hospital=report.hospital, created_at__gte=year_start, created_at__lt=year_end, complaint_type="complaint" + ) + cls._create_source_breakdowns(report, all_complaints) + cls._create_department_breakdown(report, all_complaints) + cls._create_location_breakdowns(report, all_complaints) + @classmethod def _calculate_response_rate(cls, report: KPIReport): """Calculate Department Response Rate (48h)""" year_start = datetime(report.year, 1, 1) - + total_numerator = 0 total_denominator = 0 - + for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) - - # Get complaints that received a response + complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, - complaint_type="complaint" + complaint_type="complaint", ) - + denominator = complaints.count() - - # Count complaints with response within 48h + numerator = 0 for complaint in complaints: - first_update = complaint.updates.order_by('created_at').first() - if first_update and complaint.created_at: - response_time = first_update.created_at - complaint.created_at + first_response = ( + complaint.updates.filter(update_type__in=["communication", "resolution"]) + .order_by("created_at") + .first() + ) + if first_response and complaint.created_at: + response_time = first_response.created_at - complaint.created_at if response_time.total_seconds() <= 48 * 3600: numerator += 1 - + monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, - } + }, ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage monthly_data.save() - + total_numerator += numerator total_denominator += denominator - + report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() - + + all_complaints = Complaint.objects.filter( + hospital=report.hospital, + created_at__gte=year_start, + created_at__lt=( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ), + complaint_type="complaint", + ) + cls._create_source_breakdowns(report, all_complaints) + cls._create_department_breakdown(report, all_complaints) + cls._create_location_breakdowns(report, all_complaints) + @classmethod def _calculate_activation_2h(cls, report: KPIReport): """Calculate Complaint Activation Within 2 Hours""" year_start = datetime(report.year, 1, 1) - + total_numerator = 0 total_denominator = 0 - + for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) - + # Get complaints with assigned_to (activated) complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, - complaint_type="complaint" + complaint_type="complaint", ) - + denominator = complaints.count() - + # Count activated within 2 hours numerator = 0 for complaint in complaints: @@ -471,81 +588,112 @@ class KPICalculationService: activation_time = complaint.assigned_at - complaint.created_at if activation_time.total_seconds() <= 2 * 3600: numerator += 1 - + monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ "numerator": numerator, "denominator": denominator, - } + }, ) monthly_data.numerator = numerator monthly_data.denominator = denominator monthly_data.calculate_percentage() monthly_data.is_below_target = monthly_data.percentage < report.target_percentage monthly_data.save() - + total_numerator += numerator total_denominator += denominator - + report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() - + + all_complaints = Complaint.objects.filter( + hospital=report.hospital, + created_at__gte=year_start, + created_at__lt=( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ), + complaint_type="complaint", + ) + cls._create_source_breakdowns(report, all_complaints) + cls._create_department_breakdown(report, all_complaints) + cls._create_location_breakdowns(report, all_complaints) + @classmethod def _calculate_unactivated(cls, report: KPIReport): """Calculate Unactivated Filled Complaints Rate""" + from apps.dashboard.models import ComplaintRequest + year_start = datetime(report.year, 1, 1) - + total_numerator = 0 total_denominator = 0 - + for month in range(1, 13): month_start = datetime(report.year, month, 1) if month == 12: month_end = datetime(report.year + 1, 1, 1) else: month_end = datetime(report.year, month + 1, 1) - - # Get all complaints + complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=month_start, created_at__lt=month_end, - complaint_type="complaint" + complaint_type="complaint", ) - + denominator = complaints.count() - # Unactivated = no assigned_to - numerator = complaints.filter(assigned_to__isnull=True).count() - + + filled_unactivated = ( + complaints.filter( + complaint_request_records__filled=True, + assigned_to__isnull=True, + ) + .distinct() + .count() + ) + monthly_data, _ = KPIReportMonthlyData.objects.get_or_create( kpi_report=report, month=month, defaults={ - "numerator": numerator, + "numerator": filled_unactivated, "denominator": denominator, - } + }, ) - monthly_data.numerator = numerator + monthly_data.numerator = filled_unactivated monthly_data.denominator = denominator monthly_data.calculate_percentage() - # Note: For unactivated, HIGHER is WORSE, so below target = above threshold - monthly_data.is_below_target = monthly_data.percentage > (100 - report.target_percentage) + monthly_data.is_below_target = monthly_data.percentage > report.threshold_percentage monthly_data.save() - - total_numerator += numerator + + total_numerator += filled_unactivated total_denominator += denominator - + report.total_numerator = total_numerator report.total_denominator = total_denominator if total_denominator > 0: report.overall_result = Decimal(str((total_numerator / total_denominator) * 100)) report.save() - + + all_complaints = Complaint.objects.filter( + hospital=report.hospital, + created_at__gte=year_start, + created_at__lt=( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ), + complaint_type="complaint", + ) + cls._create_source_breakdowns(report, all_complaints) + cls._create_department_breakdown(report, all_complaints) + cls._create_location_breakdowns(report, all_complaints) + @classmethod def _get_source_breakdown(cls, complaints) -> Dict[str, int]: """Get breakdown of complaints by source""" @@ -554,61 +702,45 @@ class KPICalculationService: source_name = complaint.source.name_en if complaint.source else "Other" sources[source_name] = sources.get(source_name, 0) + 1 return sources - + @classmethod def _create_source_breakdowns(cls, report: KPIReport, complaints): """Create source breakdown records for pie chart""" # Delete existing report.source_breakdowns.all().delete() - + # Count by source source_counts = {} total = complaints.count() - + for complaint in complaints: source_name = complaint.source.name_en if complaint.source else "Other" source_counts[source_name] = source_counts.get(source_name, 0) + 1 - + # Create records for source_name, count in source_counts.items(): percentage = (count / total * 100) if total > 0 else 0 KPIReportSourceBreakdown.objects.create( - kpi_report=report, - source_name=source_name, - complaint_count=count, - percentage=Decimal(str(percentage)) + kpi_report=report, source_name=source_name, complaint_count=count, percentage=Decimal(str(percentage)) ) - + @classmethod def _create_department_breakdown(cls, report: KPIReport, complaints): """Create department breakdown records""" - # Delete existing report.department_breakdowns.all().delete() - - # Categorize departments - department_categories = { - "medical": ["Medical", "Surgery", "Cardiology", "Orthopedics", "Pediatrics", "Obstetrics", "Gynecology"], - "nursing": ["Nursing", "ICU", "ER", "OR"], - "admin": ["Administration", "HR", "Finance", "IT", "Reception"], - "support": ["Housekeeping", "Maintenance", "Security", "Cafeteria", "Transport"], - } - - for category, keywords in department_categories.items(): - # Find departments matching this category - dept_complaints = complaints.filter( - department__name__icontains=keywords[0] - ) - for keyword in keywords[1:]: - dept_complaints = dept_complaints | complaints.filter( - department__name__icontains=keyword - ) - + + for category, keywords in DEPARTMENT_CATEGORY_KEYWORDS.items(): + q_objects = Q() + for keyword in keywords: + q_objects |= Q(department__name__icontains=keyword) + + dept_complaints = complaints.filter(q_objects).distinct() + complaint_count = dept_complaints.count() resolved_count = dept_complaints.filter( status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED] ).count() - - # Calculate average resolution days + avg_days = None resolved_complaints = dept_complaints.filter(resolved_at__isnull=False) if resolved_complaints.exists(): @@ -617,39 +749,75 @@ class KPICalculationService: days = (c.resolved_at - c.created_at).total_seconds() / (24 * 3600) total_days += days avg_days = Decimal(str(total_days / resolved_complaints.count())) - - # Get top areas (subcategories) + top_areas_list = [] - for c in dept_complaints[:10]: + seen = set() + for c in dept_complaints[:20]: if c.category: - top_areas_list.append(c.category.name_en) - top_areas = "\n".join(list(set(top_areas_list))[:5]) if top_areas_list else "" - + name = c.category.name_en + if name not in seen: + top_areas_list.append(name) + seen.add(name) + top_areas = "\n".join(top_areas_list[:5]) if top_areas_list else "" + KPIReportDepartmentBreakdown.objects.create( kpi_report=report, department_category=category, complaint_count=complaint_count, resolved_count=resolved_count, avg_resolution_days=avg_days, - top_areas=top_areas + top_areas=top_areas, ) + @classmethod + def _create_location_breakdowns(cls, report: KPIReport, complaints): + """Create location breakdown records (In-Patient, Out-Patient, ER)""" + report.location_breakdowns.all().delete() + + location_categories = { + "In-Patient": ["inpatient", "in-patient", "ip", "in patient"], + "Out-Patient": ["outpatient", "out-patient", "opd", "out patient", "op"], + "ER": ["emergency", "er", "ed", "a&e", "accident"], + } + + total = complaints.count() + if total == 0: + return + + for loc_type, keywords in location_categories.items(): + q_objects = Q() + for keyword in keywords: + q_objects |= Q(location__name_en__icontains=keyword) + q_objects |= Q(main_section__name_en__icontains=keyword) + + loc_complaints = complaints.filter(q_objects).distinct() + count = loc_complaints.count() + percentage = (count / total * 100) if total > 0 else 0 + + KPIReportLocationBreakdown.objects.create( + kpi_report=report, + location_type=loc_type, + complaint_count=count, + percentage=Decimal(str(round(percentage, 2))), + ) @classmethod def generate_ai_analysis(cls, report: KPIReport) -> dict: """ Generate AI analysis for a KPI report. - + Dispatches to specific analysis methods based on report type. - + Args: report: KPIReport instance - + Returns: Dictionary containing AI-generated analysis """ if report.report_type == KPIReportType.RESOLUTION_72H: return cls._generate_72h_resolution_analysis(report) + elif report.report_type == KPIReportType.PATIENT_EXPERIENCE: + return cls._generate_patient_experience_analysis(report) elif report.report_type == KPIReportType.N_PAD_001: return cls._generate_n_pad_001_analysis(report) elif report.report_type == KPIReportType.SATISFACTION_RESOLUTION: @@ -662,67 +830,79 @@ class KPICalculationService: return cls._generate_unactivated_analysis(report) else: logger.warning(f"AI analysis not supported for report type: {report.report_type}") - return {'error': 'Analysis not supported for this report type'} - + return {"error": "Analysis not supported for this report type"} + @classmethod def _generate_72h_resolution_analysis(cls, report: KPIReport) -> dict: """Generate AI analysis for 72-Hour Resolution Rate report""" from apps.core.ai_service import AIService - + try: - # Get report data overall_percentage = float(report.overall_result) if report.overall_result else 0 total_complaints = report.total_denominator resolved_within_72h = report.total_numerator - + # Get monthly data - monthly_data = report.monthly_data.all().order_by('month') + monthly_data = report.monthly_data.all().order_by("month") monthly_breakdown = [] for md in monthly_data: if md.denominator > 0: - monthly_breakdown.append({ - 'month': md.month, - 'percentage': float(md.percentage) if md.percentage else 0, - 'resolved': md.numerator, - 'total': md.denominator - }) - + monthly_breakdown.append( + { + "month": md.month, + "percentage": float(md.percentage) if md.percentage else 0, + "resolved": md.numerator, + "total": md.denominator, + } + ) + # Get source breakdowns source_breakdowns = report.source_breakdowns.all() source_data = [] for sb in source_breakdowns: - source_data.append({ - 'source': sb.source_name, - 'complaints': sb.complaint_count, - 'percentage': float(sb.percentage) if sb.percentage else 0 - }) - + source_data.append( + { + "source": sb.source_name, + "complaints": sb.complaint_count, + "percentage": float(sb.percentage) if sb.percentage else 0, + } + ) + # Get department breakdowns dept_breakdowns = report.department_breakdowns.all() dept_data = [] for db in dept_breakdowns: - dept_data.append({ - 'category': db.get_department_category_display(), - 'complaints': db.complaint_count, - 'resolved': db.resolved_count, - 'avg_days': float(db.avg_resolution_days) if db.avg_resolution_days else None - }) - + dept_data.append( + { + "category": db.get_department_category_display(), + "complaints": db.complaint_count, + "resolved": db.resolved_count, + "avg_days": float(db.avg_resolution_days) if db.avg_resolution_days else None, + } + ) + # Calculate resolution time buckets year_start = datetime(report.year, 1, 1) - year_end = datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) - - complaints = Complaint.objects.filter( + year_end = ( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ) + + all_complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=year_start, created_at__lt=year_end, complaint_type="complaint", - status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED] ) - + + complaints = all_complaints.filter( + status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED], + ) + + resolved_count = complaints.count() + resolved_24h = resolved_48h = resolved_72h = resolved_over_72h = 0 dept_response_times = {} - + for c in complaints: if c.resolved_at and c.created_at: hours = (c.resolved_at - c.created_at).total_seconds() / 3600 @@ -734,26 +914,96 @@ class KPICalculationService: resolved_72h += 1 else: resolved_over_72h += 1 - + dept_name = c.department.name if c.department else "Unassigned" if dept_name not in dept_response_times: dept_response_times[dept_name] = [] dept_response_times[dept_name].append(hours / 24) - - slow_departments = [] + + # G2: Categorize slow departments into 4 categories + medical_slow = [] + nursing_slow = [] + admin_slow = [] + support_slow = [] + for dept, days_list in dept_response_times.items(): if days_list: avg_days = sum(days_list) / len(days_list) - if avg_days > 2: - slow_departments.append({'name': dept, 'avg_days': round(avg_days, 1)}) - slow_departments.sort(key=lambda x: x['avg_days'], reverse=True) - - escalated_count = complaints.filter(updates__update_type='escalation').distinct().count() - closed_count = complaints.filter(status=ComplaintStatus.CLOSED).count() - + entry = {"name": dept, "avg_days": round(avg_days, 1), "count": len(days_list)} + name_lower = dept.lower() + if any(k in name_lower for k in DEPARTMENT_CATEGORY_KEYWORDS["medical"]): + medical_slow.append(entry) + elif any(k in name_lower for k in DEPARTMENT_CATEGORY_KEYWORDS["nursing"]): + nursing_slow.append(entry) + elif any(k in name_lower for k in DEPARTMENT_CATEGORY_KEYWORDS["admin"]): + admin_slow.append(entry) + elif any(k in name_lower for k in DEPARTMENT_CATEGORY_KEYWORDS["support"]): + support_slow.append(entry) + + medical_slow.sort(key=lambda x: x["avg_days"], reverse=True) + nursing_slow.sort(key=lambda x: x["avg_days"], reverse=True) + admin_slow.sort(key=lambda x: x["avg_days"], reverse=True) + support_slow.sort(key=lambda x: x["avg_days"], reverse=True) + + # G3: Per-source resolution time buckets (CCHI and MOH) + moh_complaints = all_complaints.filter( + status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED], + source__name_en__icontains="moh", + ) + cchi_complaints = all_complaints.filter( + status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED], + source__name_en__icontains="chi", + ) + + moh_24h = moh_48h = moh_72h = moh_over = moh_total = 0 + for c in moh_complaints: + moh_total += 1 + if c.resolved_at and c.created_at: + hours = (c.resolved_at - c.created_at).total_seconds() / 3600 + if hours <= 24: + moh_24h += 1 + elif hours <= 48: + moh_48h += 1 + elif hours <= 72: + moh_72h += 1 + else: + moh_over += 1 + + cchi_24h = cchi_48h = cchi_72h = cchi_over = cchi_total = 0 + for c in cchi_complaints: + cchi_total += 1 + if c.resolved_at and c.created_at: + hours = (c.resolved_at - c.created_at).total_seconds() / 3600 + if hours <= 24: + cchi_24h += 1 + elif hours <= 48: + cchi_48h += 1 + elif hours <= 72: + cchi_72h += 1 + else: + cchi_over += 1 + + # G4: Count complaints lacking responses + complaints_no_response = 0 + for c in all_complaints: + has_response = c.updates.filter(update_type="response").exists() + if not has_response: + complaints_no_response += 1 + + escalated_count = all_complaints.filter(updates__update_type="escalation").distinct().count() + closed_count = all_complaints.filter(status=ComplaintStatus.CLOSED).count() + target_percentage = float(report.target_percentage) - performance_status = "met" if overall_percentage >= target_percentage else "below target" - + threshold_percentage = float(report.threshold_percentage) + performance_status = ( + "met" + if overall_percentage >= target_percentage + else "below threshold" + if overall_percentage >= threshold_percentage + else "below threshold - action needed" + ) + + # G5: Use resolved_count as denominator for time buckets prompt = f"""Analyze this 72-Hour Complaint Resolution KPI report. REPORT DATA: @@ -761,15 +1011,48 @@ REPORT DATA: - Hospital: {report.hospital.name} - Overall Resolution Rate (≤72h): {overall_percentage:.2f}% - Target: {target_percentage:.2f}% +- Threshold: {threshold_percentage:.2f}% - Status: {performance_status} - Total Complaints: {total_complaints} +- Total Resolved: {resolved_count} - Resolved within 72h: {resolved_within_72h} -RESOLUTION TIME BREAKDOWN: -- Within 24h: {resolved_24h} ({(resolved_24h/total_complaints*100) if total_complaints else 0:.2f}%) -- Within 48h: {resolved_48h} ({(resolved_48h/total_complaints*100) if total_complaints else 0:.2f}%) -- Within 72h: {resolved_72h} ({(resolved_72h/total_complaints*100) if total_complaints else 0:.2f}%) -- After 72h: {resolved_over_72h} ({(resolved_over_72h/total_complaints*100) if total_complaints else 0:.2f}%) +RESOLUTION TIME BREAKDOWN (of resolved complaints): +- Within 24h: {resolved_24h} ({(resolved_24h / resolved_count * 100) if resolved_count else 0:.2f}%) +- Within 48h: {resolved_48h} ({(resolved_48h / resolved_count * 100) if resolved_count else 0:.2f}%) +- Within 72h: {resolved_72h} ({(resolved_72h / resolved_count * 100) if resolved_count else 0:.2f}%) +- After 72h: {resolved_over_72h} ({(resolved_over_72h / resolved_count * 100) if resolved_count else 0:.2f}%) + +AVERAGE RESPONSE TIME BY DEPARTMENT CATEGORY (exceeding 48 hours): +Medical Department: +{chr(10).join([f"- {d['name']}: {d['avg_days']} Days ({d['count']} complaints)" for d in medical_slow[:10]]) if medical_slow else "- All within timeframe"} + +Non-Medical Department: +{chr(10).join([f"- {d['name']}: {d['avg_days']} Days ({d['count']} complaints)" for d in admin_slow[:10]]) if admin_slow else "- All within timeframe"} + +Nursing Department: +{chr(10).join([f"- {d['name']}: {d['avg_days']} Days ({d['count']} complaints)" for d in nursing_slow[:5]]) if nursing_slow else "- All within timeframe"} + +Support Services: +{chr(10).join([f"- {d['name']}: {d['avg_days']} Days ({d['count']} complaints)" for d in support_slow[:5]]) if support_slow else "- No complaints received"} + +COMPLAINTS LACKING RESPONSES: {complaints_no_response} + +ESCALATION & CLOSURE: +- Escalated Complaints: {escalated_count} +- Closed Complaints: {closed_count} + +MOH RESOLUTION BREAKDOWN ({moh_total} resolved complaints): +- Within 24h: {moh_24h} ({(moh_24h / moh_total * 100) if moh_total else 0:.2f}%) +- Within 48h: {moh_48h} ({(moh_48h / moh_total * 100) if moh_total else 0:.2f}%) +- Within 72h: {moh_72h} ({(moh_72h / moh_total * 100) if moh_total else 0:.2f}%) +- After 72h: {moh_over} ({(moh_over / moh_total * 100) if moh_total else 0:.2f}%) + +CCHI RESOLUTION BREAKDOWN ({cchi_total} resolved complaints): +- Within 24h: {cchi_24h} ({(cchi_24h / cchi_total * 100) if cchi_total else 0:.2f}%) +- Within 48h: {cchi_48h} ({(cchi_48h / cchi_total * 100) if cchi_total else 0:.2f}%) +- Within 72h: {cchi_72h} ({(cchi_72h / cchi_total * 100) if cchi_total else 0:.2f}%) +- After 72h: {cchi_over} ({(cchi_over / cchi_total * 100) if cchi_total else 0:.2f}%) MONTHLY TREND: {chr(10).join([f"- Month {m['month']}: {m['percentage']:.2f}% ({m['resolved']}/{m['total']})" for m in monthly_breakdown])} @@ -778,154 +1061,399 @@ SOURCE BREAKDOWN: {chr(10).join([f"- {s['source']}: {s['complaints']} ({s['percentage']:.2f}%)" for s in source_data])} DEPARTMENT PERFORMANCE: -{chr(10).join([f"- {d['category']}: {d['complaints']} complaints, {d['resolved']} resolved" + (f", Avg {d['avg_days']:.1f} days" if d['avg_days'] else "") for d in dept_data])} +{chr(10).join([f"- {d['category']}: {d['complaints']} complaints, {d['resolved']} resolved" + (f", Avg {d['avg_days']:.1f} days" if d["avg_days"] else "") for d in dept_data])} -SLOW DEPARTMENTS: -{chr(10).join([f"- {d['name']}: {d['avg_days']} days" for d in slow_departments[:10]]) if slow_departments else "- None"} - -ESCALATED: {escalated_count} | CLOSED: {closed_count} - -Provide analysis in JSON format with: executive_summary, performance_analysis, key_findings, reasons_for_delays, resolution_time_analysis, department_analysis, source_analysis, recommendations.""" +Provide analysis in JSON format: +{{ + "executive_summary": "Overview of {overall_percentage:.2f}% 72h resolution rate vs {target_percentage:.0f}% target", + "comparison_to_target": "Analysis of meeting target and threshold", + "resolution_time_analysis": {{ + "within_24h": {{"count": {resolved_24h}, "percentage": "{(resolved_24h / resolved_count * 100) if resolved_count else 0:.2f}%"}}, + "within_48h": {{"count": {resolved_48h}, "percentage": "{(resolved_48h / resolved_count * 100) if resolved_count else 0:.2f}%"}}, + "within_72h": {{"count": {resolved_72h}, "percentage": "{(resolved_72h / resolved_count * 100) if resolved_count else 0:.2f}%"}}, + "after_72h": {{"count": {resolved_over_72h}, "percentage": "{(resolved_over_72h / resolved_count * 100) if resolved_count else 0:.2f}%"}} + }}, + "slow_departments": {{ + "medical": ["Dept Name: X Days"], + "non_medical": ["Dept Name: X Days"], + "nursing": ["Dept Name: X Days"], + "support_services": ["Dept Name: X Days"] + }}, + "source_resolution_breakdown": {{ + "moh": {{"total": {moh_total}, "within_24h": {moh_24h}, "within_48h": {moh_48h}, "within_72h": {moh_72h}, "after_72h": {moh_over}}}, + "cchi": {{"total": {cchi_total}, "within_24h": {cchi_24h}, "within_48h": {cchi_48h}, "within_72h": {cchi_72h}, "after_72h": {cchi_over}}} + }}, + "escalation_closure": {{ + "escalated": {escalated_count}, + "closed": {closed_count}, + "complaints_no_response": {complaints_no_response} + }}, + "key_findings": ["Finding 1", "Finding 2"], + "recommendations": ["Recommendation 1", "Recommendation 2"] +}}""" system_prompt = """You are a healthcare KPI analysis expert. Analyze 72-Hour Complaint Resolution data and provide: -1. Executive summary -2. Performance analysis vs target -3. Key findings -4. Root causes for delays -5. Department insights -6. Source insights (CHI, MOH, etc.) +1. Executive summary with performance status vs target and threshold +2. Resolution time breakdown analysis +3. Slow departments analysis by category (Medical, Non-Medical, Nursing, Support) +4. Source-specific resolution insights (MOH vs CCHI breakdown) +5. Escalation and response status +6. Key findings with specific numbers 7. Actionable recommendations -Be specific and use actual numbers.""" +Be specific and use actual numbers from the data.""" response = AIService.chat_completion( prompt=prompt, system_prompt=system_prompt, response_format="json_object", temperature=0.3, - max_tokens=2000 + max_tokens=2500, ) - + import json + analysis = json.loads(response) - analysis['_metadata'] = { - 'generated_at': timezone.now().isoformat(), - 'report_id': str(report.id), - 'report_type': report.report_type, - 'hospital': report.hospital.name, - 'year': report.year, - 'month': report.month + analysis["_metadata"] = { + "generated_at": timezone.now().isoformat(), + "report_id": str(report.id), + "report_type": report.report_type, + "hospital": report.hospital.name, + "year": report.year, + "month": report.month, } - + report.ai_analysis = analysis report.ai_analysis_generated_at = timezone.now() - report.save(update_fields=['ai_analysis', 'ai_analysis_generated_at']) - + report.save(update_fields=["ai_analysis", "ai_analysis_generated_at"]) + logger.info(f"AI analysis generated for 72h report {report.id}") return analysis - + except Exception as e: logger.exception(f"Error generating 72h analysis: {e}") - return {'error': str(e), 'executive_summary': 'Analysis failed'} - + return {"error": str(e), "executive_summary": "Analysis failed"} + + @classmethod + def _generate_patient_experience_analysis(cls, report: KPIReport) -> dict: + """Generate AI analysis for Patient Experience Score (MOH-1) report""" + from apps.core.ai_service import AIService + from apps.surveys.models import SurveyInstance, SurveyStatus + + try: + overall_percentage = float(report.overall_result) if report.overall_result else 0 + total_surveyed = report.total_denominator + satisfied_count = report.total_numerator + + monthly_data = report.monthly_data.all().order_by("month") + monthly_breakdown = [] + for md in monthly_data: + if md.denominator > 0: + monthly_breakdown.append( + { + "month": md.month, + "month_name": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ][md.month - 1], + "percentage": float(md.percentage) if md.percentage else 0, + "satisfied": md.numerator, + "total": md.denominator, + "avg_score": float(md.details.get("avg_score", 0)) if md.details else 0, + } + ) + + year_start = datetime(report.year, 1, 1) + year_end = ( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ) + + from django.db.models import Avg as AvgAgg, Count + + surveys = SurveyInstance.objects.filter( + survey_template__hospital=report.hospital, + survey_template__survey_type__in=["stage", "general"], + status=SurveyStatus.COMPLETED, + completed_at__gte=year_start, + completed_at__lt=year_end, + ) + + total_completed = surveys.count() + + avg_score_val = surveys.aggregate(avg=AvgAgg("total_score"))["avg"] or 0 + + dissatisfied = surveys.filter(total_score__lt=4).count() + neutral_count = surveys.filter(total_score__gte=3, total_score__lt=4).count() + very_satisfied = surveys.filter(total_score__gte=4.5).count() + + negative_surveys = surveys.filter(is_negative=True).count() + + delivery_breakdown = surveys.values("delivery_channel").annotate(count=Count("id")).order_by("-count") + delivery_lines = [] + for d in delivery_breakdown: + label = dict(SurveyInstance._meta.get_field("delivery_channel").choices).get( + d["delivery_channel"], d["delivery_channel"] + ) + pct = (d["count"] / total_completed * 100) if total_completed else 0 + delivery_lines.append(f"- {label}: {d['count']} ({pct:.1f}%)") + + lowest_month = min(monthly_breakdown, key=lambda x: x["percentage"]) if monthly_breakdown else None + highest_month = max(monthly_breakdown, key=lambda x: x["percentage"]) if monthly_breakdown else None + + prev_month = report.month - 1 if report.month > 1 else 12 + prev_year = report.year if report.month > 1 else report.year - 1 + prev_percentage = None + try: + prev_report = KPIReport.objects.get( + report_type=report.report_type, + hospital=report.hospital, + year=prev_year, + month=prev_month, + ) + prev_percentage = float(prev_report.overall_result) if prev_report.overall_result else None + except KPIReport.DoesNotExist: + pass + + target_percentage = float(report.target_percentage) + threshold_percentage = float(report.threshold_percentage) + + performance_status = ( + "Target Met" + if overall_percentage >= target_percentage + else "Below Target" + if overall_percentage >= threshold_percentage + else "Below Threshold" + ) + + prev_trend = "" + if prev_percentage is not None: + diff = overall_percentage - prev_percentage + direction = "improved" if diff > 0 else "declined" if diff < 0 else "stable" + prev_trend = f" ({direction} from {prev_month}/{prev_year}: {prev_percentage:.1f}%)" + + prompt = f"""Analyze this Patient Experience Score (MOH-1) KPI report. + +REPORT DATA: +- Report Period: {report.year}-{report.month:02d} +- Hospital: {report.hospital.name} +- Patient Experience Score: {overall_percentage:.2f}%{prev_trend} +- Target: {target_percentage:.2f}% +- Threshold: {threshold_percentage:.2f}% +- Status: {performance_status} +- Total Patients Surveyed: {total_surveyed} +- Total Completed Responses: {total_completed} +- Average Score: {avg_score_val:.2f} + +SATISFACTION BREAKDOWN: +- Very Satisfied (score >= 4.5): {very_satisfied} ({(very_satisfied / total_completed * 100) if total_completed else 0:.1f}%) +- Satisfied/Good (score >= 4): {satisfied_count} ({(satisfied_count / total_completed * 100) if total_completed else 0:.1f}%) +- Neutral (3-4): {neutral_count} ({(neutral_count / total_completed * 100) if total_completed else 0:.1f}%) +- Dissatisfied (<3): {dissatisfied} ({(dissatisfied / total_completed * 100) if total_completed else 0:.1f}%) +- Flagged as Negative: {negative_surveys} + +DELIVERY CHANNEL BREAKDOWN: +{chr(10).join(delivery_lines) if delivery_lines else "- No data"} + +MONTHLY TRENDS: +{chr(10).join([f"- {m['month_name']}: {m['percentage']:.2f}% ({m['satisfied']}/{m['total']}, avg score: {m['avg_score']:.2f})" for m in monthly_breakdown])} + +LOWEST: {lowest_month["month_name"] if lowest_month else "N/A"} ({lowest_month["percentage"]:.2f}%) +HIGHEST: {highest_month["month_name"] if highest_month else "N/A"} ({highest_month["percentage"]:.2f}%) + +Provide analysis in JSON format: +{{ + "executive_summary": "Overview of {overall_percentage:.2f}% patient experience score vs {target_percentage:.0f}% target", + "comparison_to_target": "Analysis vs target ({target_percentage:.0f}%) and threshold ({threshold_percentage:.0f}%){prev_trend}", + "patient_experience_score": "{overall_percentage:.2f}%", + "satisfaction_breakdown": {{ + "very_satisfied": {very_satisfied}, + "satisfied_good": {satisfied_count}, + "neutral": {neutral_count}, + "dissatisfied": {dissatisfied}, + "negative_flagged": {negative_surveys} + }}, + "delivery_channel_analysis": "Analysis of survey delivery channels", + "monthly_trend": ["Month: Percentage%"], + "response_statistics": {{ + "total_surveyed": {total_surveyed}, + "total_completed": {total_completed}, + "average_score": "{avg_score_val:.2f}" + }}, + "key_findings": ["Finding 1", "Finding 2"], + "recommendations": ["Recommendation 1", "Recommendation 2"] +}}""" + + system_prompt = """You are a healthcare patient experience analysis expert for MOH-1 Patient Experience Score. +Analyze the patient experience survey data and provide: +1. Executive summary of patient experience performance +2. Comparison to target (85%) and threshold (78%) +3. Previous month trend comparison +4. Satisfaction breakdown analysis +5. Delivery channel insights +6. Month-by-month trend analysis +7. Key findings with specific numbers +8. Actionable recommendations for improving patient experience + +Focus on identifying drivers of satisfaction and dissatisfaction.""" + + response = AIService.chat_completion( + prompt=prompt, + system_prompt=system_prompt, + response_format="json_object", + temperature=0.3, + max_tokens=2500, + ) + + import json + + analysis = json.loads(response) + analysis["_metadata"] = { + "generated_at": timezone.now().isoformat(), + "report_id": str(report.id), + "report_type": report.report_type, + "hospital": report.hospital.name, + "year": report.year, + "month": report.month, + } + + report.ai_analysis = analysis + report.ai_analysis_generated_at = timezone.now() + report.save(update_fields=["ai_analysis", "ai_analysis_generated_at"]) + + logger.info(f"AI analysis generated for Patient Experience report {report.id}") + return analysis + + except Exception as e: + logger.exception(f"Error generating Patient Experience analysis: {e}") + return {"error": str(e), "executive_summary": "Analysis failed"} + @classmethod def _generate_n_pad_001_analysis(cls, report: KPIReport) -> dict: """Generate AI analysis for N-PAD-001 Resolution to Patient Complaints report""" from apps.core.ai_service import AIService - + try: # Get report data overall_percentage = float(report.overall_result) if report.overall_result else 0 total_complaints = report.total_denominator resolved_complaints = report.total_numerator - + # Get date range year_start = datetime(report.year, 1, 1) - year_end = datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) - + year_end = ( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ) + # Query complaints for detailed analysis complaints = Complaint.objects.filter( hospital=report.hospital, created_at__gte=year_start, created_at__lt=year_end, - complaint_type="complaint" - ).select_related('department', 'location', 'main_section', 'source') - + complaint_type="complaint", + ).select_related("department", "location", "main_section", "source") + # Count by status closed_count = complaints.filter(status=ComplaintStatus.CLOSED).count() resolved_count = complaints.filter(status=ComplaintStatus.RESOLVED).count() - escalated_count = complaints.filter(updates__update_type='escalation').distinct().count() - + escalated_count = complaints.filter(updates__update_type="escalation").distinct().count() + # Department breakdown dept_counts = {} for c in complaints: - dept_cat = 'Unassigned' + dept_cat = "Unassigned" if c.department: name = c.department.name.lower() - if any(k in name for k in ['medical', 'surgery', 'cardiology', 'orthopedics', 'pediatrics', 'obstetrics', 'gynecology', 'er', 'lab', 'icu']): - dept_cat = 'Medical' - elif any(k in name for k in ['nursing', 'nurse']): - dept_cat = 'Nursing' - elif any(k in name for k in ['admin', 'reception', 'manager', 'approval', 'report']): - dept_cat = 'Admin' - elif any(k in name for k in ['housekeeping', 'maintenance', 'security', 'cafeteria', 'transport']): - dept_cat = 'Support Services' + if any( + k in name + for k in [ + "medical", + "surgery", + "cardiology", + "orthopedics", + "pediatrics", + "obstetrics", + "gynecology", + "er", + "lab", + "icu", + ] + ): + dept_cat = "Medical" + elif any(k in name for k in ["nursing", "nurse"]): + dept_cat = "Nursing" + elif any(k in name for k in ["admin", "reception", "manager", "approval", "report"]): + dept_cat = "Admin" + elif any(k in name for k in ["housekeeping", "maintenance", "security", "cafeteria", "transport"]): + dept_cat = "Support Services" else: dept_cat = c.department.name - + if dept_cat not in dept_counts: - dept_counts[dept_cat] = {'closed': 0, 'escalated': 0, 'total': 0} - dept_counts[dept_cat]['total'] += 1 + dept_counts[dept_cat] = {"closed": 0, "escalated": 0, "total": 0} + dept_counts[dept_cat]["total"] += 1 if c.status == ComplaintStatus.CLOSED: - dept_counts[dept_cat]['closed'] += 1 - + dept_counts[dept_cat]["closed"] += 1 + # Escalation reasons - escalated_complaints = complaints.filter(updates__update_type='escalation').distinct() + escalated_complaints = complaints.filter(updates__update_type="escalation").distinct() patient_dissatisfaction = 0 lack_of_response = 0 - + for c in escalated_complaints: - updates = c.updates.filter(update_type='escalation') + updates = c.updates.filter(update_type="escalation") for u in updates: - message = u.message.lower() if u.message else '' - if any(k in message for k in ['dissatisfaction', 'dissatisfied', 'unhappy', 'not satisfied', 'complain']): + message = u.message.lower() if u.message else "" + if any( + k in message + for k in ["dissatisfaction", "dissatisfied", "unhappy", "not satisfied", "complain"] + ): patient_dissatisfaction += 1 - if any(k in message for k in ['no response', 'lack of response', 'no reply', 'delay']): + if any(k in message for k in ["no response", "lack of response", "no reply", "delay"]): lack_of_response += 1 - + # Source breakdown source_counts = {} for c in complaints: - source = 'Unknown' + source = "Unknown" if c.source: - source_name = c.source.name_en.lower() if c.source.name_en else '' - if 'moh' in source_name: - source = 'MOH' - elif 'chi' in source_name: - source = 'CHI' + source_name = c.source.name_en.lower() if c.source.name_en else "" + if "moh" in source_name: + source = "MOH" + elif "chi" in source_name: + source = "CHI" else: source = c.source.name_en - elif c.complaint_source_type == 'external': - source = 'Patient/Relative' + elif c.complaint_source_type == "external": + source = "Patient/Relative" else: - source = 'Internal' - + source = "Internal" + source_counts[source] = source_counts.get(source, 0) + 1 - + # Location breakdown location_counts = {} for c in complaints: - loc = c.location.name_en if c.location else 'Unknown' + loc = c.location.name_en if c.location else "Unknown" location_counts[loc] = location_counts.get(loc, 0) + 1 - + # Main department breakdown main_dept_counts = { - 'Medical': dept_counts.get('Medical', {}).get('total', 0), - 'Nursing': dept_counts.get('Nursing', {}).get('total', 0), - 'Admin': dept_counts.get('Admin', {}).get('total', 0), - 'Support Services': dept_counts.get('Support Services', {}).get('total', 0) + "Medical": dept_counts.get("Medical", {}).get("total", 0), + "Nursing": dept_counts.get("Nursing", {}).get("total", 0), + "Admin": dept_counts.get("Admin", {}).get("total", 0), + "Support Services": dept_counts.get("Support Services", {}).get("total", 0), } - + # Top departments by complaints (detailed) detailed_dept_counts = {} for c in complaints: @@ -934,11 +1462,11 @@ Be specific and use actual numbers.""" if dept_name not in detailed_dept_counts: detailed_dept_counts[dept_name] = 0 detailed_dept_counts[dept_name] += 1 - + top_departments = sorted(detailed_dept_counts.items(), key=lambda x: x[1], reverse=True)[:10] - + target_percentage = float(report.target_percentage) - + prompt = f"""Analyze this N-PAD-001 Resolution to Patient Complaints KPI report. REPORT DATA: @@ -951,20 +1479,20 @@ REPORT DATA: - Closed: {closed_count} CLOSED COMPLAINTS BY DEPARTMENT CATEGORY: -- Medical: {dept_counts.get('Medical', {}).get('closed', 0)} -- Nursing: {dept_counts.get('Nursing', {}).get('closed', 0)} -- Admin: {dept_counts.get('Admin', {}).get('closed', 0)} -- Support Services: {dept_counts.get('Support Services', {}).get('closed', 0)} +- Medical: {dept_counts.get("Medical", {}).get("closed", 0)} +- Nursing: {dept_counts.get("Nursing", {}).get("closed", 0)} +- Admin: {dept_counts.get("Admin", {}).get("closed", 0)} +- Support Services: {dept_counts.get("Support Services", {}).get("closed", 0)} ESCALATED COMPLAINTS: {escalated_count} - Due to Patient Dissatisfaction: {patient_dissatisfaction} - Due to Lack of Response: {lack_of_response} BY DEPARTMENT CATEGORY: -- Medical: {dept_counts.get('Medical', {}).get('escalated', 0)} -- Nursing: {dept_counts.get('Nursing', {}).get('escalated', 0)} -- Admin: {dept_counts.get('Admin', {}).get('escalated', 0)} -- Support: {dept_counts.get('Support Services', {}).get('escalated', 0)} +- Medical: {dept_counts.get("Medical", {}).get("escalated", 0)} +- Nursing: {dept_counts.get("Nursing", {}).get("escalated", 0)} +- Admin: {dept_counts.get("Admin", {}).get("escalated", 0)} +- Support: {dept_counts.get("Support Services", {}).get("escalated", 0)} TOP DEPARTMENTS BY COMPLAINTS: {chr(10).join([f"- {name}: {count}" for name, count in top_departments])} @@ -1030,127 +1558,158 @@ Be specific with numbers and focus on actionable insights.""" system_prompt=system_prompt, response_format="json_object", temperature=0.3, - max_tokens=2500 + max_tokens=2500, ) - + import json + analysis = json.loads(response) - analysis['_metadata'] = { - 'generated_at': timezone.now().isoformat(), - 'report_id': str(report.id), - 'report_type': report.report_type, - 'hospital': report.hospital.name, - 'year': report.year, - 'month': report.month + analysis["_metadata"] = { + "generated_at": timezone.now().isoformat(), + "report_id": str(report.id), + "report_type": report.report_type, + "hospital": report.hospital.name, + "year": report.year, + "month": report.month, } - + report.ai_analysis = analysis report.ai_analysis_generated_at = timezone.now() - report.save(update_fields=['ai_analysis', 'ai_analysis_generated_at']) - + report.save(update_fields=["ai_analysis", "ai_analysis_generated_at"]) + logger.info(f"AI analysis generated for N-PAD-001 report {report.id}") return analysis - + except Exception as e: logger.exception(f"Error generating N-PAD-001 analysis: {e}") - return {'error': str(e), 'executive_summary': 'Analysis failed'} + return {"error": str(e), "executive_summary": "Analysis failed"} - @classmethod def _generate_satisfaction_resolution_analysis(cls, report: KPIReport) -> dict: """Generate AI analysis for Overall Satisfaction with Resolution (MOH-3) report""" from apps.core.ai_service import AIService - + from apps.surveys.models import SurveyInstance, SurveyStatus + try: - # Get report data overall_percentage = float(report.overall_result) if report.overall_result else 0 total_responses = report.total_denominator satisfied_responses = report.total_numerator - - # Get monthly data - monthly_data = report.monthly_data.all().order_by('month') + + monthly_data = report.monthly_data.all().order_by("month") monthly_breakdown = [] for md in monthly_data: if md.denominator > 0: - monthly_breakdown.append({ - 'month': md.month, - 'month_name': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][md.month - 1], - 'percentage': float(md.percentage) if md.percentage else 0, - 'satisfied': md.numerator, - 'total': md.denominator - }) - - # Calculate response statistics from surveys + monthly_breakdown.append( + { + "month": md.month, + "month_name": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ][md.month - 1], + "percentage": float(md.percentage) if md.percentage else 0, + "satisfied": md.numerator, + "total": md.denominator, + } + ) + year_start = datetime(report.year, 1, 1) - year_end = datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) - - from apps.surveys.models import SurveyInstance, SurveyStatus - - # Get resolution surveys for complaints in this period + year_end = ( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ) + + total_complaints_received = Complaint.objects.filter( + hospital=report.hospital, + created_at__gte=year_start, + created_at__lt=year_end, + complaint_type="complaint", + ).count() + surveys = SurveyInstance.objects.filter( - complaint__hospital=report.hospital, - complaint__created_at__gte=year_start, - complaint__created_at__lt=year_end, - template__survey_type='resolution', - status__in=[SurveyStatus.COMPLETED, SurveyStatus.PARTIAL] - ).select_related('complaint', 'complaint__source') - - total_surveys = surveys.count() - no_response = surveys.filter(status=SurveyStatus.PARTIAL).count() - completed_responses = surveys.filter(status=SurveyStatus.COMPLETED).count() - - # Satisfaction breakdown - satisfied_count = 0 + survey_template__hospital=report.hospital, + survey_template__survey_type="complaint_resolution", + completed_at__gte=year_start, + completed_at__lt=year_end, + ) + + completed_surveys = surveys.filter(status=SurveyStatus.COMPLETED) + completed_count = completed_surveys.count() + + no_response = total_complaints_received - completed_count dissatisfied_count = 0 neutral_count = 0 - - for survey in surveys: - responses = survey.responses.all() - # Look for satisfaction rating (usually 1-5 or 1-10) - for resp in responses: - if resp.question and ('satisfaction' in resp.question.text.lower() or - 'satisfied' in resp.question.text.lower()): - try: - value = int(resp.value) - if value >= 4: # 4-5 satisfied - satisfied_count += 1 - elif value <= 2: # 1-2 dissatisfied - dissatisfied_count += 1 - else: # 3 neutral - neutral_count += 1 - except (ValueError, TypeError): - # Text response - value = str(resp.value).lower() - if any(w in value for w in ['satisfied', 'happy', 'good', 'excellent']): - satisfied_count += 1 - elif any(w in value for w in ['dissatisfied', 'unhappy', 'bad', 'poor']): - dissatisfied_count += 1 + + for survey in completed_surveys: + score = survey.total_score + if score is not None: + if score >= 4: + pass + elif score <= 2: + dissatisfied_count += 1 + else: + neutral_count += 1 + else: + for resp in survey.responses.all(): + if resp.question and ( + "satisfaction" in resp.question.text.lower() or "satisfied" in resp.question.text.lower() + ): + val = resp.numeric_value + if val is not None: + val_int = int(val) + if val_int <= 2: + dissatisfied_count += 1 + elif val_int == 3: + neutral_count += 1 else: - neutral_count += 1 - - # Calculate participation rate - participation_rate = (completed_responses / total_surveys * 100) if total_surveys > 0 else 0 - - # MOH specific satisfaction (from source) - moh_surveys = surveys.filter(complaint__source__name_en__icontains='MOH') - moh_satisfied = 0 - moh_total = moh_surveys.count() - for survey in moh_surveys: - for resp in survey.responses.all(): - if resp.question and ('satisfaction' in resp.question.text.lower()): - try: - if int(resp.value) >= 4: - moh_satisfied += 1 - except (ValueError, TypeError): - pass - moh_satisfaction_rate = (moh_satisfied / moh_total * 100) if moh_total > 0 else 0 - - # Identify lowest month for analysis - lowest_month = min(monthly_breakdown, key=lambda x: x['percentage']) if monthly_breakdown else None - + text = (resp.text_value or resp.choice_value or "").lower() + if any(w in text for w in ["dissatisfied", "unhappy", "bad", "poor"]): + dissatisfied_count += 1 + else: + neutral_count += 1 + break + + response_rate = (completed_count / total_complaints_received * 100) if total_complaints_received > 0 else 0 + satisfaction_excl_no_response = (satisfied_responses / completed_count * 100) if completed_count > 0 else 0 + + moh_complaints = Complaint.objects.filter( + hospital=report.hospital, + created_at__gte=year_start, + created_at__lt=year_end, + complaint_type="complaint", + source__name_en__icontains="moh", + ).count() + + cchi_complaints = Complaint.objects.filter( + hospital=report.hospital, + created_at__gte=year_start, + created_at__lt=year_end, + complaint_type="complaint", + source__name_en__icontains="chi", + ).count() + target_percentage = float(report.target_percentage) - + threshold_percentage = float(report.threshold_percentage) + + performance_status = ( + "target met" + if overall_percentage >= target_percentage + else "below target but above threshold" + if overall_percentage >= threshold_percentage + else "below threshold - action needed" + ) + + lowest_month = min(monthly_breakdown, key=lambda x: x["percentage"]) if monthly_breakdown else None + highest_month = max(monthly_breakdown, key=lambda x: x["percentage"]) if monthly_breakdown else None + prompt = f"""Analyze this Overall Satisfaction with Complaint Resolution (MOH-3) KPI report. REPORT DATA: @@ -1158,56 +1717,57 @@ REPORT DATA: - Hospital: {report.hospital.name} - Overall Satisfaction Rate: {overall_percentage:.2f}% - Target: {target_percentage:.2f}% -- Total Complaints Received: {total_responses} - -MONTHLY SATISFACTION TRENDS: -{chr(10).join([f"- {m['month_name']}: {m['percentage']:.2f}%" for m in monthly_breakdown])} - -RESPONSE STATISTICS: -- Total Surveys Sent: {total_surveys} -- Completed Responses: {completed_responses} -- No Response: {no_response} -- Participation Rate: {participation_rate:.2f}% +- Threshold: {threshold_percentage:.2f}% +- Status: {performance_status} SATISFACTION BREAKDOWN: -- Satisfied: {satisfied_count} -- Dissatisfied: {dissatisfied_count} +- Total Complaints Received: {total_complaints_received} +- Total Responses: {completed_count} +- No Response: {no_response} +- Satisfied: {satisfied_responses} +- Not Satisfied: {dissatisfied_count} - Neutral: {neutral_count} -MOH SPECIFIC: -- MOH Satisfaction Rate: {moh_satisfaction_rate:.2f}% -- Total MOH Responses: {moh_total} +SATISFACTION RATE: {satisfaction_excl_no_response:.2f}% (after excluding no responses) +RESPONSE RATE: {response_rate:.2f}% ({completed_count}/{total_complaints_received}) -LOWEST PERFORMANCE MONTH: -{lowest_month['month_name'] if lowest_month else 'N/A'}: {lowest_month['percentage']:.2f}%{' (significant drop)' if lowest_month and lowest_month['percentage'] < 50 else ''} +MONTHLY SATISFACTION TRENDS: +{chr(10).join([f"- {m['month_name']}: {m['percentage']:.2f}% ({m['satisfied']}/{m['total']})" for m in monthly_breakdown])} -SATISFACTION AFTER EXCLUDING NO RESPONSE: -{(satisfied_count / completed_responses * 100) if completed_responses > 0 else 0:.2f}% +LOWEST PERFORMANCE: {lowest_month["month_name"] if lowest_month else "N/A"} ({lowest_month["percentage"]:.2f}%) +HIGHEST PERFORMANCE: {highest_month["month_name"] if highest_month else "N/A"} ({highest_month["percentage"]:.2f}%) + +SOURCE BREAKDOWN: +- MOH complaints: {moh_complaints} +- CCHI complaints: {cchi_complaints} Provide analysis in JSON format: {{ - "executive_summary": "Overview of satisfaction performance at {overall_percentage:.2f}%", - "satisfaction_rate_by_month": [ + "executive_summary": "Overview of {overall_percentage:.2f}% satisfaction rate vs {target_percentage:.0f}% target", + "comparison_to_target": "Analysis of meeting target ({target_percentage:.0f}%) and threshold ({threshold_percentage:.0f}%)", + "satisfaction_rate": "{satisfaction_excl_no_response:.2f}%", + "response_rate": "{response_rate:.2f}%", + "participation_analysis": "Analysis of {response_rate:.1f}% response rate and {no_response} complaints with no response", + "monthly_trend": [ "Month: Percentage%" ], - "moh_satisfaction_analysis": "Analysis of MOH satisfaction at {moh_satisfaction_rate:.2f}%", - "performance_overview": "Satisfaction rate after excluding no-response is X%", - "participation_rate_analysis": "Response rate is {participation_rate:.2f}% - analysis of patient engagement", - "key_issues": [ - "Issue 1: Long resolution times causing frustration", - "Issue 2: Patients dissatisfied with outcomes", - "Issue 3: etc." - ], - "response_statistics": {{ - "total_complaints": {total_responses}, - "total_responses": {completed_responses}, - "no_response": {no_response}, - "escalated_without_response": 0, - "closed_without_response": 0, - "satisfied": {satisfied_count}, - "dissatisfied": {dissatisfied_count}, + "satisfaction_breakdown": {{ + "satisfied": {satisfied_responses}, + "not_satisfied": {dissatisfied_count}, "neutral": {neutral_count} }}, + "response_statistics": {{ + "total_complaints_received": {total_complaints_received}, + "total_responses": {completed_count}, + "no_response": {no_response}, + "satisfied": {satisfied_responses}, + "not_satisfied": {dissatisfied_count}, + "neutral": {neutral_count} + }}, + "key_issues": [ + "Issue 1: description", + "Issue 2: description" + ], "recommendations": [ "Recommendation 1", "Recommendation 2" @@ -1217,13 +1777,12 @@ Provide analysis in JSON format: system_prompt = """You are a healthcare patient satisfaction analysis expert for MOH-3 Overall Satisfaction with Complaint Resolution. Analyze the satisfaction survey data and provide: 1. Executive summary of overall satisfaction performance -2. Month-by-month satisfaction rate analysis with trends -3. MOH-specific satisfaction analysis -4. Performance overview after excluding non-responses -5. Participation rate analysis and patient engagement insights -6. Key issues causing dissatisfaction (long wait times, poor outcomes, etc.) -7. Response statistics breakdown -8. Actionable recommendations for improving satisfaction +2. Comparison to target and threshold (80% target, 70% threshold) +3. Satisfaction rate analysis (excluding non-responses) +4. Response rate / participation analysis +5. Month-by-month satisfaction rate analysis with trends +6. Key issues causing dissatisfaction +7. Actionable recommendations for improving satisfaction Focus on identifying why patients are dissatisfied and provide practical solutions.""" @@ -1232,180 +1791,211 @@ Focus on identifying why patients are dissatisfied and provide practical solutio system_prompt=system_prompt, response_format="json_object", temperature=0.3, - max_tokens=2500 + max_tokens=2500, ) - + import json + analysis = json.loads(response) - analysis['_metadata'] = { - 'generated_at': timezone.now().isoformat(), - 'report_id': str(report.id), - 'report_type': report.report_type, - 'hospital': report.hospital.name, - 'year': report.year, - 'month': report.month + analysis["_metadata"] = { + "generated_at": timezone.now().isoformat(), + "report_id": str(report.id), + "report_type": report.report_type, + "hospital": report.hospital.name, + "year": report.year, + "month": report.month, } - + report.ai_analysis = analysis report.ai_analysis_generated_at = timezone.now() - report.save(update_fields=['ai_analysis', 'ai_analysis_generated_at']) - + report.save(update_fields=["ai_analysis", "ai_analysis_generated_at"]) + logger.info(f"AI analysis generated for Satisfaction Resolution report {report.id}") return analysis - + except Exception as e: logger.exception(f"Error generating Satisfaction Resolution analysis: {e}") - return {'error': str(e), 'executive_summary': 'Analysis failed'} + return {"error": str(e), "executive_summary": "Analysis failed"} - @classmethod def _generate_response_rate_analysis(cls, report: KPIReport) -> dict: """Generate AI analysis for Department Response Rate (Dep-KPI-4) report""" from apps.core.ai_service import AIService - + try: - # Get report data overall_percentage = float(report.overall_result) if report.overall_result else 0 total_complaints = report.total_denominator responded_within_48h = report.total_numerator - - # Get date range + + # Previous month comparison + prev_month = report.month - 1 if report.month > 1 else 12 + prev_year = report.year if report.month > 1 else report.year - 1 + prev_percentage = None + try: + prev_report = KPIReport.objects.get( + report_type=report.report_type, + hospital=report.hospital, + year=prev_year, + month=prev_month, + ) + prev_percentage = float(prev_report.overall_result) if prev_report.overall_result else None + except KPIReport.DoesNotExist: + pass + year_start = datetime(report.year, 1, 1) - year_end = datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) - - # Query complaints needing department response - complaints = Complaint.objects.filter( - hospital=report.hospital, - created_at__gte=year_start, - created_at__lt=year_end, - complaint_type="complaint", - status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED] - ).select_related('department', 'location', 'main_section').prefetch_related('updates') - - # Calculate response times + year_end = ( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ) + + all_complaints = ( + Complaint.objects.filter( + hospital=report.hospital, + created_at__gte=year_start, + created_at__lt=year_end, + complaint_type="complaint", + ) + .select_related("department", "source") + .prefetch_related("updates") + ) + responded_48_72h = 0 responded_over_72h = 0 + complaints_no_response = 0 dept_response_times = {} - - for c in complaints: - # Find first department response - first_response = c.updates.filter( - update_type='response', - created_by__isnull=False - ).order_by('created_at').first() - + + for c in all_complaints: + first_response = ( + c.updates.filter(update_type__in=["communication", "resolution"]).order_by("created_at").first() + ) + if first_response and c.created_at: hours = (first_response.created_at - c.created_at).total_seconds() / 3600 dept_name = c.department.name if c.department else "Unassigned" - + if dept_name not in dept_response_times: dept_response_times[dept_name] = [] dept_response_times[dept_name].append(hours / 24) - - if 48 < hours <= 72: - responded_48_72h += 1 - elif hours > 72: - responded_over_72h += 1 - - # Calculate average response times for slow departments (>48 hours) - slow_departments = [] - for dept, days_list in dept_response_times.items(): - if days_list: - avg_days = sum(days_list) / len(days_list) - if avg_days > 2: # More than 48 hours - slow_departments.append({ - 'name': dept, - 'avg_days': round(avg_days, 1), - 'complaint_count': len(days_list) - }) - slow_departments.sort(key=lambda x: x['avg_days'], reverse=True) - - # Categorize slow departments + + if hours > 48: + if hours <= 72: + responded_48_72h += 1 + else: + responded_over_72h += 1 + else: + complaints_no_response += 1 + + total_exceeding_48h = responded_48_72h + responded_over_72h + + escalated_count = all_complaints.filter(updates__update_type="escalation").distinct().count() + medical_slow = [] nursing_slow = [] admin_slow = [] support_slow = [] - - for dept in slow_departments: - name_lower = dept['name'].lower() - if any(k in name_lower for k in ['medical', 'surgery', 'cardiology', 'orthopedics', 'pediatrics', 'obstetrics', 'gynecology', 'er', 'lab', 'icu', 'clinic', 'ward', 'physiotherapy', 'psychiatric']): - medical_slow.append(dept) - elif any(k in name_lower for k in ['nursing', 'nurse', 'opd nursing', 'icu nursing']): - nursing_slow.append(dept) - elif any(k in name_lower for k in ['admin', 'reception', 'manager', 'approval', 'report']): - admin_slow.append(dept) - elif any(k in name_lower for k in ['housekeeping', 'maintenance', 'security', 'cafeteria', 'transport', 'support']): - support_slow.append(dept) - - # Count total exceeding 48 hours - total_exceeding_48h = responded_48_72h + responded_over_72h - + + for dept, days_list in dept_response_times.items(): + if days_list: + avg_days = sum(days_list) / len(days_list) + entry = {"name": dept, "avg_days": round(avg_days, 1), "complaint_count": len(days_list)} + name_lower = dept.lower() + if any(k in name_lower for k in DEPARTMENT_CATEGORY_KEYWORDS["medical"]): + if avg_days > 2: + medical_slow.append(entry) + elif any(k in name_lower for k in DEPARTMENT_CATEGORY_KEYWORDS["nursing"]): + if avg_days > 2: + nursing_slow.append(entry) + elif any(k in name_lower for k in DEPARTMENT_CATEGORY_KEYWORDS["admin"]): + if avg_days > 2: + admin_slow.append(entry) + elif any(k in name_lower for k in DEPARTMENT_CATEGORY_KEYWORDS["support"]): + if avg_days > 2: + support_slow.append(entry) + + medical_slow.sort(key=lambda x: x["avg_days"], reverse=True) + nursing_slow.sort(key=lambda x: x["avg_days"], reverse=True) + admin_slow.sort(key=lambda x: x["avg_days"], reverse=True) + support_slow.sort(key=lambda x: x["avg_days"], reverse=True) + target_percentage = float(report.target_percentage) - threshold_percentage = 70.0 # Standard threshold - + threshold_percentage = float(report.threshold_percentage) + + performance_status = ( + "Target Met" + if overall_percentage >= target_percentage + else "Below Target" + if overall_percentage >= threshold_percentage + else "Below Threshold" + ) + + prev_trend = "" + if prev_percentage is not None: + diff = overall_percentage - prev_percentage + direction = "improved" if diff > 0 else "declined" if diff < 0 else "stable" + prev_trend = f" ({direction} from {prev_month}/{prev_year}: {prev_percentage:.1f}%)" + prompt = f"""Analyze this Department Response Rate (Dep-KPI-4) KPI report. REPORT DATA: - Report Period: {report.year}-{report.month:02d} - Hospital: {report.hospital.name} -- Response Rate (≤48h): {overall_percentage:.2f}% +- Response Rate (≤48h): {overall_percentage:.2f}%{prev_trend} - Target: {target_percentage:.2f}% - Threshold: {threshold_percentage:.2f}% -- Status: {"Target Met" if overall_percentage >= target_percentage else "Below Target" if overall_percentage >= threshold_percentage else "Below Threshold"} +- Status: {performance_status} - Total Complaints: {total_complaints} - Responded within 48h: {responded_within_48h} COMPLAINTS EXCEEDING 48 HOURS: {total_exceeding_48h} -- 48-72 hours: {responded_48_72h} complaints ({(responded_48_72h/total_complaints*100) if total_complaints else 0:.2f}%) -- Over 72 hours: {responded_over_72h} complaints ({(responded_over_72h/total_complaints*100) if total_complaints else 0:.2f}%) +- 48-72 hours: {responded_48_72h} complaints ({(responded_48_72h / total_complaints * 100) if total_complaints else 0:.2f}%) +- Over 72 hours: {responded_over_72h} complaints ({(responded_over_72h / total_complaints * 100) if total_complaints else 0:.2f}%) +- Escalated due to delays: {escalated_count} +- No response: {complaints_no_response} -SLOW DEPARTMENTS (Avg > 48 hours): +DEPARTMENTS WITH MORE THAN 48 HOURS OF RESPONSE: MEDICAL DEPARTMENT: -{chr(10).join([f"- {d['name']}: {d['avg_days']} Days" for d in medical_slow[:10]]) if medical_slow else "- None exceeding timeframe"} - -NURSING DEPARTMENT: -{chr(10).join([f"- {d['name']}: {d['avg_days']} Days" for d in nursing_slow[:5]]) if nursing_slow else "- None exceeding timeframe"} +{chr(10).join([f"- {d['name']}: {d['avg_days']} Days ({d['complaint_count']} complaints)" for d in medical_slow[:10]]) if medical_slow else "- None exceeding timeframe"} NON-MEDICAL DEPARTMENT: -{chr(10).join([f"- {d['name']}: {d['avg_days']} Days" for d in admin_slow[:5]]) if admin_slow else "- All within timeframe"} +{chr(10).join([f"- {d['name']}: {d['avg_days']} Days ({d['complaint_count']} complaints)" for d in admin_slow[:10]]) if admin_slow else "- All within timeframe"} + +NURSING DEPARTMENT: +{chr(10).join([f"- {d['name']}: {d['avg_days']} Days ({d['complaint_count']} complaints)" for d in nursing_slow[:5]]) if nursing_slow else "- None exceeding timeframe"} SUPPORT SERVICES: -{chr(10).join([f"- {d['name']}: {d['avg_days']} Days" for d in support_slow[:5]]) if support_slow else "- No complaints received"} +{chr(10).join([f"- {d['name']}: {d['avg_days']} Days ({d['complaint_count']} complaints)" for d in support_slow[:5]]) if support_slow else "- No complaints received"} Provide analysis in JSON format: {{ "executive_summary": "Overview of {overall_percentage:.2f}% response rate vs {target_percentage:.0f}% target", - "comparison_to_target": "Analysis of meeting target and threshold", + "comparison_to_target": "Analysis of meeting target ({target_percentage:.0f}%) and threshold ({threshold_percentage:.0f}%){prev_trend}", "response_rate": "{overall_percentage:.2f}%", "complaints_exceeding_48h": {{ "total": {total_exceeding_48h}, - "within_48_72h": {{"count": {responded_48_72h}, "percentage": "{(responded_48_72h/total_complaints*100) if total_complaints else 0:.2f}%"}}, - "over_72h": {{"count": {responded_over_72h}, "percentage": "{(responded_over_72h/total_complaints*100) if total_complaints else 0:.2f}%"}} + "within_48_72h": {{"count": {responded_48_72h}, "percentage": "{(responded_48_72h / total_complaints * 100) if total_complaints else 0:.2f}%"}}, + "over_72h": {{"count": {responded_over_72h}, "percentage": "{(responded_over_72h / total_complaints * 100) if total_complaints else 0:.2f}%"}} }}, + "escalated_due_to_delays": {escalated_count}, + "complaints_no_response": {complaints_no_response}, "slow_departments": {{ - "medical": [ - "Dept Name: X Days" - ], - "nursing": [ - "Dept Name: X Days" - ], - "non_medical": "All within timeframe OR list", - "support_services": "No complaints OR list" + "medical": ["Dept Name: X Days"], + "non_medical": ["Dept Name: X Days"], + "nursing": ["Dept Name: X Days"], + "support_services": ["Dept Name: X Days"] }}, - "recommendations": [ - "Recommendation 1: Follow up with department heads", - "Recommendation 2: Contact relevant persons directly" - ] + "key_findings": ["Finding 1", "Finding 2"], + "recommendations": ["Recommendation 1", "Recommendation 2"] }}""" system_prompt = """You are a healthcare department response analysis expert for Dep-KPI-4 Department Response Rate. Analyze the response time data and provide: 1. Executive summary of response rate performance -2. Comparison to target and threshold (70%) -3. Breakdown of complaints exceeding 48 hours -4. Analysis of slow departments by category (Medical, Nursing, Non-Medical, Support) -5. Specific department names with their average response times -6. Actionable recommendations for improving response times +2. Comparison to target and threshold +3. Previous month trend comparison +4. Breakdown of complaints exceeding 48 hours +5. Escalated complaints due to department delays +6. Analysis of slow departments by category (Medical, Nursing, Non-Medical, Support) +7. Specific department names with their average response times +8. Actionable recommendations for improving response times Focus on identifying which departments need improvement and practical follow-up strategies.""" @@ -1414,127 +2004,103 @@ Focus on identifying which departments need improvement and practical follow-up system_prompt=system_prompt, response_format="json_object", temperature=0.3, - max_tokens=2000 + max_tokens=2000, ) - + import json + analysis = json.loads(response) - analysis['_metadata'] = { - 'generated_at': timezone.now().isoformat(), - 'report_id': str(report.id), - 'report_type': report.report_type, - 'hospital': report.hospital.name, - 'year': report.year, - 'month': report.month + analysis["_metadata"] = { + "generated_at": timezone.now().isoformat(), + "report_id": str(report.id), + "report_type": report.report_type, + "hospital": report.hospital.name, + "year": report.year, + "month": report.month, } - + report.ai_analysis = analysis report.ai_analysis_generated_at = timezone.now() - report.save(update_fields=['ai_analysis', 'ai_analysis_generated_at']) - + report.save(update_fields=["ai_analysis", "ai_analysis_generated_at"]) + logger.info(f"AI analysis generated for Response Rate report {report.id}") return analysis - + except Exception as e: logger.exception(f"Error generating Response Rate analysis: {e}") - return {'error': str(e), 'executive_summary': 'Analysis failed'} + return {"error": str(e), "executive_summary": "Analysis failed"} - @classmethod def _generate_activation_2h_analysis(cls, report: KPIReport) -> dict: """Generate AI analysis for Complaint Activation Within 2 Hours (KPI-6) report""" from apps.core.ai_service import AIService - + try: - # Get report data overall_percentage = float(report.overall_result) if report.overall_result else 0 total_complaints = report.total_denominator activated_within_2h = report.total_numerator - - # Get date range - year_start = datetime(report.year, 1, 1) - year_end = datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) - - # Query complaints for activation analysis + + month_start = datetime(report.year, report.month, 1) + month_end = ( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ) + complaints = Complaint.objects.filter( hospital=report.hospital, - created_at__gte=year_start, - created_at__lt=year_end, - complaint_type="complaint" - ).select_related('assigned_to', 'activated_by').prefetch_related('updates') - - # Calculate activation statistics + created_at__gte=month_start, + created_at__lt=month_end, + complaint_type="complaint", + ).select_related("assigned_to") + activated_complaints = [] delayed_complaints = [] - - # Staff activation breakdown + staff_activation = {} staff_delays = {} - + for c in complaints: - # Check if complaint was activated - activation_update = c.updates.filter( - update_type='status_change', - message__icontains='activat' - ).order_by('created_at').first() - - if activation_update and c.created_at: - hours = (activation_update.created_at - c.created_at).total_seconds() / 3600 - - # Get staff who activated - staff_name = activation_update.created_by.get_full_name() if activation_update.created_by else "Unknown" - + if c.assigned_at and c.created_at: + hours = (c.assigned_at - c.created_at).total_seconds() / 3600 + + staff_name = c.assigned_to.get_full_name() if c.assigned_to else "Unknown" + if hours <= 2: - activated_complaints.append({ - 'id': str(c.id), - 'hours': hours, - 'staff': staff_name - }) - + activated_complaints.append({"id": str(c.id), "hours": hours, "staff": staff_name}) + if staff_name not in staff_activation: staff_activation[staff_name] = 0 staff_activation[staff_name] += 1 else: - delayed_complaints.append({ - 'id': str(c.id), - 'hours': hours, - 'staff': staff_name - }) - + delayed_complaints.append({"id": str(c.id), "hours": hours, "staff": staff_name}) + if staff_name not in staff_delays: staff_delays[staff_name] = 0 staff_delays[staff_name] += 1 - - # Sort staff by activation count + top_activators = sorted(staff_activation.items(), key=lambda x: x[1], reverse=True) top_delays = sorted(staff_delays.items(), key=lambda x: x[1], reverse=True) - - # Calculate percentages + total_count = len(activated_complaints) + len(delayed_complaints) activated_count = len(activated_complaints) delay_count = len(delayed_complaints) - + activated_percentage = (activated_count / total_count * 100) if total_count > 0 else 0 delay_percentage = (delay_count / total_count * 100) if total_count > 0 else 0 - + target_percentage = float(report.target_percentage) - threshold_percentage = 80.0 # Typical threshold for activation - - # Compare to previous month if available + threshold_percentage = float(report.threshold_percentage) if report.threshold_percentage else 90.0 + prev_month = report.month - 1 if report.month > 1 else 12 prev_year = report.year if report.month > 1 else report.year - 1 - + try: prev_report = KPIReport.objects.get( - report_type=report.report_type, - hospital=report.hospital, - year=prev_year, - month=prev_month + report_type=report.report_type, hospital=report.hospital, year=prev_year, month=prev_month ) prev_percentage = float(prev_report.overall_result) if prev_report.overall_result else 0 except KPIReport.DoesNotExist: prev_percentage = None - + prompt = f"""Analyze this Complaint Activation Within 2 Hours (KPI-6) report. REPORT DATA: @@ -1547,19 +2113,19 @@ REPORT DATA: - Total Complaints: {total_count} PREVIOUS MONTH COMPARISON: -{prev_month}/{prev_year}: {prev_percentage:.2f}% {'(improved)' if prev_percentage and overall_percentage > prev_percentage else '(declined)' if prev_percentage and overall_percentage < prev_percentage else '(stable)' if prev_percentage else '(no data)'} +{prev_month}/{prev_year}: {prev_percentage:.2f}% {"(improved)" if prev_percentage and overall_percentage > prev_percentage else "(declined)" if prev_percentage and overall_percentage < prev_percentage else "(stable)" if prev_percentage else "(no data)"} ACTIVATED WITHIN 2 HOURS: - Total: {activated_count} Complaints ({activated_percentage:.2f}%) BY STAFF: -{chr(10).join([f"- {name}: {count} Complaints - {(count/activated_count*100) if activated_count else 0:.2f}%" for name, count in top_activators[:5]]) if top_activators else "- No data"} +{chr(10).join([f"- {name}: {count} Complaints - {(count / activated_count * 100) if activated_count else 0:.2f}%" for name, count in top_activators[:5]]) if top_activators else "- No data"} DELAYS IN ACTIVATION: - Total: {delay_count} Complaints ({delay_percentage:.2f}%) BY STAFF: -{chr(10).join([f"- {name}: {count} Complaints - {(count/delay_count*100) if delay_count else 0:.2f}%" for name, count in top_delays[:5]]) if top_delays else "- No delays"} +{chr(10).join([f"- {name}: {count} Complaints - {(count / delay_count * 100) if delay_count else 0:.2f}%" for name, count in top_delays[:5]]) if top_delays else "- No delays"} Provide analysis in JSON format: {{ @@ -1610,188 +2176,128 @@ Focus on identifying why activations are delayed and practical solutions.""" system_prompt=system_prompt, response_format="json_object", temperature=0.3, - max_tokens=2000 + max_tokens=2000, ) - + import json + analysis = json.loads(response) - analysis['_metadata'] = { - 'generated_at': timezone.now().isoformat(), - 'report_id': str(report.id), - 'report_type': report.report_type, - 'hospital': report.hospital.name, - 'year': report.year, - 'month': report.month + analysis["_metadata"] = { + "generated_at": timezone.now().isoformat(), + "report_id": str(report.id), + "report_type": report.report_type, + "hospital": report.hospital.name, + "year": report.year, + "month": report.month, } - + report.ai_analysis = analysis report.ai_analysis_generated_at = timezone.now() - report.save(update_fields=['ai_analysis', 'ai_analysis_generated_at']) - + report.save(update_fields=["ai_analysis", "ai_analysis_generated_at"]) + logger.info(f"AI analysis generated for Activation 2h report {report.id}") return analysis - + except Exception as e: logger.exception(f"Error generating Activation 2h analysis: {e}") - return {'error': str(e), 'executive_summary': 'Analysis failed'} + return {"error": str(e), "executive_summary": "Analysis failed"} - @classmethod def _generate_unactivated_analysis(cls, report: KPIReport) -> dict: """Generate AI analysis for Unactivated Filled Complaints Rate (KPI-7) report""" from apps.core.ai_service import AIService - + from apps.dashboard.models import ComplaintRequest + try: - # Get report data overall_percentage = float(report.overall_result) if report.overall_result else 0 - total_filled_complaints = report.total_denominator - unactivated_complaints = report.total_numerator - - # Get date range - year_start = datetime(report.year, 1, 1) - year_end = datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) - - # Query complaints for unactivated analysis - complaints = Complaint.objects.filter( - hospital=report.hospital, - created_at__gte=year_start, - created_at__lt=year_end, - complaint_type="complaint" - ).select_related('created_by', 'source').prefetch_related('updates') - - # Categorize complaints - on_hold_count = 0 - not_filled_count = 0 - filled_count = 0 - barcode_count = 0 - - # Unactivation reasons - reasons = { - 'incomplete_by_patient': 0, - 'resolved_immediately': 0, - 'not_meet_criteria': 0, - 'transferred_to_observation': 0, - 'complainant_withdraw': 0, - 'repeated_from_chi': 0, - 'other': 0 - } - - # Staff breakdown + total_complaints = report.total_denominator + unactivated_filled = report.total_numerator + + month_start = datetime(report.year, report.month, 1) + month_end = ( + datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1) + ) + + all_requests = ComplaintRequest.objects.filter( + complaint__hospital=report.hospital, + complaint__created_at__gte=month_start, + complaint__created_at__lt=month_end, + complaint__complaint_type="complaint", + ).select_related("staff", "complaint") + + on_hold_count = all_requests.filter(on_hold=True).count() + filled_count = all_requests.filter(filled=True).count() + not_filled_count = all_requests.filter(not_filled=True).count() + barcode_count = all_requests.filter(from_barcode=True).count() + + filling_times = dict(ComplaintRequest.FILLING_TIME_CHOICES) + time_breakdown = {} + for t in ComplaintRequest.FILLING_TIME_CHOICES: + time_breakdown[t[0]] = all_requests.filter(filling_time_category=t[0]).count() + staff_stats = {} - - # Time to fill breakdown - filled_same_time = 0 - filled_within_6h = 0 - filled_6h_to_24h = 0 - filled_after_1d = 0 - time_not_mentioned = 0 - - for c in complaints: - # Check status and categorization - if c.status == 'open' or c.status == 'pending': - on_hold_count += 1 - - # Check if filled or not - updates = c.updates.all() - has_content = len(c.description) > 50 if c.description else False - - if has_content: - filled_count += 1 - else: - not_filled_count += 1 - - # Track staff - staff_name = c.created_by.get_full_name() if c.created_by else "Unknown" + for req in all_requests: + staff_name = req.staff.get_full_name() if req.staff else "Unknown" if staff_name not in staff_stats: - staff_stats[staff_name] = {'filled': 0, 'not_filled': 0, 'total': 0} - staff_stats[staff_name]['total'] += 1 - - if has_content: - staff_stats[staff_name]['filled'] += 1 - else: - staff_stats[staff_name]['not_filled'] += 1 - - # Analyze unactivation reasons from updates/metadata - metadata = c.metadata or {} - reason = metadata.get('unactivation_reason', '').lower() - - if 'incomplete' in reason or 'patient' in reason: - reasons['incomplete_by_patient'] += 1 - elif 'resolved' in reason or 'immediate' in reason: - reasons['resolved_immediately'] += 1 - elif 'criteria' in reason or 'not meet' in reason: - reasons['not_meet_criteria'] += 1 - elif 'observation' in reason or 'transferred' in reason: - reasons['transferred_to_observation'] += 1 - elif 'withdraw' in reason: - reasons['complainant_withdraw'] += 1 - elif 'chi' in reason or 'repeated' in reason: - reasons['repeated_from_chi'] += 1 - else: - reasons['other'] += 1 - - # Build staff breakdown table - staff_table = [] - for staff, stats in staff_stats.items(): - staff_table.append({ - 'name': staff, - 'total': stats['total'], - 'filled': stats['filled'], - 'not_filled': stats['not_filled'] - }) - - # Sort by total - staff_table.sort(key=lambda x: x['total'], reverse=True) - + staff_stats[staff_name] = {"filled": 0, "not_filled": 0, "total": 0} + staff_stats[staff_name]["total"] += 1 + if req.filled: + staff_stats[staff_name]["filled"] += 1 + if req.not_filled: + staff_stats[staff_name]["not_filled"] += 1 + + staff_table = sorted( + [ + {"name": s, "total": v["total"], "filled": v["filled"], "not_filled": v["not_filled"]} + for s, v in staff_stats.items() + ], + key=lambda x: x["total"], + reverse=True, + ) + target_percentage = float(report.target_percentage) - threshold_percentage = 5.0 # Standard threshold - + threshold_percentage = float(report.threshold_percentage) if report.threshold_percentage else 5.0 + + performance_status = ( + "Within Target" + if overall_percentage <= target_percentage + else "Above Threshold - Action Needed" + if overall_percentage <= threshold_percentage + else "Critical - Exceeds Acceptable Limit" + ) + prompt = f"""Analyze this Unactivated Filled Complaints Rate (KPI-7) report. REPORT DATA: - Report Period: {report.year}-{report.month:02d} - Hospital: {report.hospital.name} -- Unactivated Rate: {overall_percentage:.2f}% +- Unactivated Filled Rate: {overall_percentage:.2f}% - Target: {target_percentage:.2f}% - Threshold: {threshold_percentage:.2f}% -- Status: {"Above Threshold (Action Needed)" if overall_percentage > threshold_percentage else "Within Acceptable Range"} +- Status: {performance_status} +- Total Complaints: {total_complaints} +- Unactivated Filled: {unactivated_filled} -COMPLAINT BREAKDOWN: -- On Hold: {on_hold_count} Complaints -- Not Filled: {not_filled_count} Complaints -- Filled: {filled_count} Complaints -- From Barcode: {barcode_count} Complaints +COMPLAINT REQUEST BREAKDOWN: +- On Hold: {on_hold_count} +- Not Filled: {not_filled_count} +- Filled: {filled_count} +- From Barcode: {barcode_count} TIME TO FILL ANALYSIS: -- Same Time: {filled_same_time} Complaints -- Within 6 Hours: {filled_within_6h} Complaints -- 6 Hours to 24 Hours: {filled_6h_to_24h} Complaints -- After 1 Day: {filled_after_1d} Complaints -- Time Not Mentioned: {time_not_mentioned} Complaints - -UNACTIVATION REASONS: -- Incomplete by Patient: {reasons['incomplete_by_patient']} Requests -- Resolved Immediately: {reasons['resolved_immediately']} Requests -- Do Not Meet Criteria: {reasons['not_meet_criteria']} Requests -- Transferred to Observations: {reasons['transferred_to_observation']} Requests -- Complainant Withdraw: {reasons['complainant_withdraw']} Request -- Repeated from CHI: {reasons['repeated_from_chi']} Request +- Same Time: {time_breakdown.get("same_time", 0)} +- Within 6 Hours: {time_breakdown.get("within_6h", 0)} +- 6 Hours to 24 Hours: {time_breakdown.get("6_to_24h", 0)} +- After 1 Day: {time_breakdown.get("after_1_day", 0)} +- Time Not Mentioned: {time_breakdown.get("not_mentioned", 0)} STAFF BREAKDOWN: -{chr(10).join([f"- {s['name']}: Total {s['total']}, Filled {s['filled']}, Not Filled {s['not_filled']}" for s in staff_table[:10]])} +{chr(10).join([f"- {s['name']}: Total {s['total']}, Filled {s['filled']}, Not Filled {s['not_filled']}" for s in staff_table[:10]]) if staff_table else "- No data"} Provide analysis in JSON format: {{ - "executive_summary": "Overview of {overall_percentage:.2f}% unactivated rate vs {threshold_percentage:.0f}% threshold", - "threshold_analysis": "Rate exceeds acceptable threshold of {threshold_percentage:.0f}% - detailed analysis", - "unactivation_reasons": [ - "X Requests - left incomplete by the patient", - "X Requests - resolved immediately", - "X Requests - do not meet activation criteria", - "X Requests - transferred to observations", - "X Request - complainant withdraw", - "X Request - repeated from CHI" - ], + "executive_summary": "Overview of {overall_percentage:.2f}% unactivated filled rate vs {threshold_percentage:.0f}% threshold", + "threshold_analysis": "Rate {"exceeds" if overall_percentage > threshold_percentage else "is within"} acceptable threshold of {threshold_percentage:.0f}%", "complaint_statistics": {{ "on_hold": {on_hold_count}, "not_filled": {not_filled_count}, @@ -1799,30 +2305,30 @@ Provide analysis in JSON format: "from_barcode": {barcode_count} }}, "time_to_fill": {{ - "same_time": {filled_same_time}, - "within_6h": {filled_within_6h}, - "6h_to_24h": {filled_6h_to_24h}, - "after_1d": {filled_after_1d}, - "not_mentioned": {time_not_mentioned} + "same_time": {time_breakdown.get("same_time", 0)}, + "within_6h": {time_breakdown.get("within_6h", 0)}, + "6h_to_24h": {time_breakdown.get("6_to_24h", 0)}, + "after_1d": {time_breakdown.get("after_1_day", 0)}, + "not_mentioned": {time_breakdown.get("not_mentioned", 0)} }}, "staff_breakdown": [ "Staff Name: Total X, Filled Y, Not Filled Z" ], + "key_findings": ["Finding 1", "Finding 2"], "recommendations": [ - "Review Complaints Portal Daily by Patient Relations Supervisor", - "Check SMS messages for filled complaints immediately", - "Establish notification reminders for staff follow-up" + "Monitoring the workflow and activation times among employees", + "Improve system notifications for prompt staff follow-up" ] }}""" system_prompt = """You are a healthcare complaint management analysis expert for KPI-7 Unactivated Filled Complaints Rate. Analyze the unactivation data and provide: -1. Executive summary of unactivation rate vs threshold -2. Threshold analysis (5% is the acceptable limit) -3. Detailed unactivation reasons breakdown -4. Complaint statistics (on hold, filled, not filled) -5. Time to fill analysis -6. Staff breakdown and performance +1. Executive summary of unactivation rate vs threshold (target: 0%, threshold: 5%) +2. Threshold analysis +3. Complaint request statistics (on hold, filled, not filled, barcode) +4. Time to fill analysis +5. Staff breakdown and performance +6. Key findings with specific numbers 7. Actionable recommendations for reducing unactivated complaints Focus on why complaints remain unactivated and practical solutions.""" @@ -1832,27 +2338,28 @@ Focus on why complaints remain unactivated and practical solutions.""" system_prompt=system_prompt, response_format="json_object", temperature=0.3, - max_tokens=2000 + max_tokens=2000, ) - + import json + analysis = json.loads(response) - analysis['_metadata'] = { - 'generated_at': timezone.now().isoformat(), - 'report_id': str(report.id), - 'report_type': report.report_type, - 'hospital': report.hospital.name, - 'year': report.year, - 'month': report.month + analysis["_metadata"] = { + "generated_at": timezone.now().isoformat(), + "report_id": str(report.id), + "report_type": report.report_type, + "hospital": report.hospital.name, + "year": report.year, + "month": report.month, } - + report.ai_analysis = analysis report.ai_analysis_generated_at = timezone.now() - report.save(update_fields=['ai_analysis', 'ai_analysis_generated_at']) - + report.save(update_fields=["ai_analysis", "ai_analysis_generated_at"]) + logger.info(f"AI analysis generated for Unactivated report {report.id}") return analysis - + except Exception as e: logger.exception(f"Error generating Unactivated analysis: {e}") - return {'error': str(e), 'executive_summary': 'Analysis failed'} + return {"error": str(e), "executive_summary": "Analysis failed"} diff --git a/apps/analytics/kpi_views.py b/apps/analytics/kpi_views.py index 4580404..5bd3d56 100644 --- a/apps/analytics/kpi_views.py +++ b/apps/analytics/kpi_views.py @@ -4,6 +4,7 @@ KPI Report Views Views for listing, viewing, and generating KPI reports. Follows the PX360 UI patterns with Tailwind, Lucide icons, and HTMX. """ + import json import logging from datetime import datetime @@ -25,12 +26,11 @@ from .kpi_models import KPIReport, KPIReportStatus, KPIReportType from .kpi_service import KPICalculationService - @login_required def kpi_report_list(request): """ KPI Report list view - + Shows all KPI reports with filtering by: - Report type - Hospital @@ -38,88 +38,96 @@ def kpi_report_list(request): - Status """ user = request.user - + # Base queryset - queryset = KPIReport.objects.select_related('hospital', 'generated_by') - + queryset = KPIReport.objects.select_related("hospital", "generated_by") + # Apply hospital filter based on user role if not user.is_px_admin(): if user.hospital: queryset = queryset.filter(hospital=user.hospital) else: queryset = KPIReport.objects.none() - + # Apply filters from request - report_type = request.GET.get('report_type') + report_type = request.GET.get("report_type") if report_type: queryset = queryset.filter(report_type=report_type) - - hospital_filter = request.GET.get('hospital') + + hospital_filter = request.GET.get("hospital") if hospital_filter and user.is_px_admin(): queryset = queryset.filter(hospital_id=hospital_filter) - - year = request.GET.get('year') + + year = request.GET.get("year") if year: queryset = queryset.filter(year=year) - - month = request.GET.get('month') + + month = request.GET.get("month") if month: queryset = queryset.filter(month=month) - - status = request.GET.get('status') + + status = request.GET.get("status") if status: queryset = queryset.filter(status=status) - + # Ordering - queryset = queryset.order_by('-year', '-month', 'report_type') - + queryset = queryset.order_by("-year", "-month", "report_type") + # Calculate statistics stats = { - 'total': queryset.count(), - 'completed': queryset.filter(status='completed').count(), - 'pending': queryset.filter(status__in=['pending', 'generating']).count(), - 'failed': queryset.filter(status='failed').count(), + "total": queryset.count(), + "completed": queryset.filter(status="completed").count(), + "pending": queryset.filter(status__in=["pending", "generating"]).count(), + "failed": queryset.filter(status="failed").count(), } - + # Pagination - page_size = int(request.GET.get('page_size', 12)) + page_size = int(request.GET.get("page_size", 12)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) - + # Get filter options - hospitals = Hospital.objects.filter(status='active') + hospitals = Hospital.objects.filter(status="active") if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) - + current_year = datetime.now().year years = list(range(current_year, current_year - 5, -1)) - + context = { - 'page_obj': page_obj, - 'reports': page_obj.object_list, - 'filters': request.GET, - 'stats': stats, - 'hospitals': hospitals, - 'years': years, - 'months': [ - (1, _('January')), (2, _('February')), (3, _('March')), - (4, _('April')), (5, _('May')), (6, _('June')), - (7, _('July')), (8, _('August')), (9, _('September')), - (10, _('October')), (11, _('November')), (12, _('December')), + "page_obj": page_obj, + "reports": page_obj.object_list, + "filters": request.GET, + "stats": stats, + "hospitals": hospitals, + "years": years, + "months": [ + (1, _("January")), + (2, _("February")), + (3, _("March")), + (4, _("April")), + (5, _("May")), + (6, _("June")), + (7, _("July")), + (8, _("August")), + (9, _("September")), + (10, _("October")), + (11, _("November")), + (12, _("December")), ], - 'report_types': KPIReportType.choices, - 'statuses': KPIReportStatus.choices, + "report_types": KPIReportType.choices, + "statuses": KPIReportStatus.choices, } - - return render(request, 'analytics/kpi_report_list.html', context) + + return render(request, "analytics/kpi_report_list.html", context) @login_required def kpi_report_detail(request, report_id): """ KPI Report detail view - + Shows the full report with: - Excel-style data table - Charts (trend and source distribution) @@ -127,35 +135,35 @@ def kpi_report_detail(request, report_id): - PDF export option """ user = request.user - - report = get_object_or_404( - KPIReport.objects.select_related('hospital', 'generated_by'), - id=report_id - ) - + + report = get_object_or_404(KPIReport.objects.select_related("hospital", "generated_by"), id=report_id) + # Check permissions if not user.is_px_admin() and user.hospital != report.hospital: - messages.error(request, _('You do not have permission to view this report.')) - return redirect('analytics:kpi_report_list') - + messages.error(request, _("You do not have permission to view this report.")) + return redirect("analytics:kpi_report_list") + # Get monthly data (1-12) - monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by('month') + monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by("month") total_data = report.monthly_data.filter(month=0).first() - + # Build monthly data array ensuring 12 months monthly_data_dict = {m.month: m for m in monthly_data_qs} monthly_data = [monthly_data_dict.get(i) for i in range(1, 13)] - + # Get source breakdowns for pie chart source_breakdowns = report.source_breakdowns.all() source_chart_data = { - 'labels': [s.source_name for s in source_breakdowns] or ['No Data'], - 'data': [float(s.percentage) for s in source_breakdowns] or [100], + "labels": [s.source_name for s in source_breakdowns] or ["No Data"], + "data": [float(s.percentage) for s in source_breakdowns] or [100], } - + # Get department breakdowns department_breakdowns = report.department_breakdowns.all() - + + # Get location breakdowns + location_breakdowns = report.location_breakdowns.all() + # Prepare trend chart data - ensure we have 12 values trend_data_values = [] for m in monthly_data: @@ -163,60 +171,70 @@ def kpi_report_detail(request, report_id): trend_data_values.append(float(m.percentage)) else: trend_data_values.append(0.0) - + trend_chart_data = { - 'labels': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], - 'data': trend_data_values, - 'target': float(report.target_percentage) if report.target_percentage else 95.0, + "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "data": trend_data_values, + "target": float(report.target_percentage) if report.target_percentage else 95.0, + "threshold": float(report.threshold_percentage) if report.threshold_percentage else 90.0, } - + context = { - 'report': report, - 'monthly_data': monthly_data, - 'total_data': total_data, - 'source_breakdowns': source_breakdowns, - 'department_breakdowns': department_breakdowns, - 'source_chart_data_json': json.dumps(source_chart_data), - 'trend_chart_data_json': json.dumps(trend_chart_data), + "report": report, + "monthly_data": monthly_data, + "total_data": total_data, + "source_breakdowns": source_breakdowns, + "department_breakdowns": department_breakdowns, + "location_breakdowns": location_breakdowns, + "source_chart_data_json": json.dumps(source_chart_data), + "trend_chart_data_json": json.dumps(trend_chart_data), } - - return render(request, 'analytics/kpi_report_detail.html', context) + + return render(request, "analytics/kpi_report_detail.html", context) @login_required def kpi_report_generate(request): """ KPI Report generation form - + Allows manual generation of KPI reports for a specific month and hospital. """ user = request.user - + # Get filter options - hospitals = Hospital.objects.filter(status='active') + hospitals = Hospital.objects.filter(status="active") if not user.is_px_admin(): if user.hospital: hospitals = hospitals.filter(id=user.hospital.id) else: hospitals = Hospital.objects.none() - + current_year = datetime.now().year years = list(range(current_year, current_year - 3, -1)) - + context = { - 'hospitals': hospitals, - 'years': years, - 'months': [ - (1, _('January')), (2, _('February')), (3, _('March')), - (4, _('April')), (5, _('May')), (6, _('June')), - (7, _('July')), (8, _('August')), (9, _('September')), - (10, _('October')), (11, _('November')), (12, _('December')), + "hospitals": hospitals, + "years": years, + "months": [ + (1, _("January")), + (2, _("February")), + (3, _("March")), + (4, _("April")), + (5, _("May")), + (6, _("June")), + (7, _("July")), + (8, _("August")), + (9, _("September")), + (10, _("October")), + (11, _("November")), + (12, _("December")), ], - 'report_types': KPIReportType.choices, + "report_types": KPIReportType.choices, } - - return render(request, 'analytics/kpi_report_generate.html', context) + + return render(request, "analytics/kpi_report_generate.html", context) @login_required @@ -226,72 +244,65 @@ def kpi_report_generate_submit(request): Handle KPI report generation form submission """ user = request.user - - report_type = request.POST.get('report_type') - hospital_id = request.POST.get('hospital') - year = request.POST.get('year') - month = request.POST.get('month') - + + report_type = request.POST.get("report_type") + hospital_id = request.POST.get("hospital") + year = request.POST.get("year") + month = request.POST.get("month") + # Validation if not all([report_type, hospital_id, year, month]): - if request.headers.get('HX-Request'): - return render(request, 'analytics/partials/kpi_generate_error.html', { - 'error': _('All fields are required.') - }) - messages.error(request, _('All fields are required.')) - return redirect('analytics:kpi_report_generate') - + if request.headers.get("HX-Request"): + return render( + request, "analytics/partials/kpi_generate_error.html", {"error": _("All fields are required.")} + ) + messages.error(request, _("All fields are required.")) + return redirect("analytics:kpi_report_generate") + # Check permissions try: hospital = Hospital.objects.get(id=hospital_id) except Hospital.DoesNotExist: - if request.headers.get('HX-Request'): - return render(request, 'analytics/partials/kpi_generate_error.html', { - 'error': _('Hospital not found.') - }) - messages.error(request, _('Hospital not found.')) - return redirect('analytics:kpi_report_generate') - + if request.headers.get("HX-Request"): + return render(request, "analytics/partials/kpi_generate_error.html", {"error": _("Hospital not found.")}) + messages.error(request, _("Hospital not found.")) + return redirect("analytics:kpi_report_generate") + if not user.is_px_admin() and user.hospital != hospital: - if request.headers.get('HX-Request'): - return render(request, 'analytics/partials/kpi_generate_error.html', { - 'error': _('You do not have permission to generate reports for this hospital.') - }) - messages.error(request, _('You do not have permission to generate reports for this hospital.')) - return redirect('analytics:kpi_report_generate') - + if request.headers.get("HX-Request"): + return render( + request, + "analytics/partials/kpi_generate_error.html", + {"error": _("You do not have permission to generate reports for this hospital.")}, + ) + messages.error(request, _("You do not have permission to generate reports for this hospital.")) + return redirect("analytics:kpi_report_generate") + try: year = int(year) month = int(month) - + # Generate the report report = KPICalculationService.generate_monthly_report( - report_type=report_type, - hospital=hospital, - year=year, - month=month, - generated_by=user + report_type=report_type, hospital=hospital, year=year, month=month, generated_by=user ) - - success_message = _('KPI Report generated successfully.') - - if request.headers.get('HX-Request'): - return render(request, 'analytics/partials/kpi_generate_success.html', { - 'report': report, - 'message': success_message - }) - + + success_message = _("KPI Report generated successfully.") + + if request.headers.get("HX-Request"): + return render( + request, "analytics/partials/kpi_generate_success.html", {"report": report, "message": success_message} + ) + messages.success(request, success_message) - return redirect('analytics:kpi_report_detail', report_id=report.id) - + return redirect("analytics:kpi_report_detail", report_id=report.id) + except Exception as e: error_message = str(e) - if request.headers.get('HX-Request'): - return render(request, 'analytics/partials/kpi_generate_error.html', { - 'error': error_message - }) + if request.headers.get("HX-Request"): + return render(request, "analytics/partials/kpi_generate_error.html", {"error": error_message}) messages.error(request, error_message) - return redirect('analytics:kpi_report_generate') + return redirect("analytics:kpi_report_generate") @login_required @@ -301,14 +312,14 @@ def kpi_report_regenerate(request, report_id): Regenerate an existing KPI report """ user = request.user - + report = get_object_or_404(KPIReport, id=report_id) - + # Check permissions if not user.is_px_admin() and user.hospital != report.hospital: - messages.error(request, _('You do not have permission to regenerate this report.')) - return redirect('analytics:kpi_report_list') - + messages.error(request, _("You do not have permission to regenerate this report.")) + return redirect("analytics:kpi_report_list") + try: # Regenerate the report KPICalculationService.generate_monthly_report( @@ -316,55 +327,55 @@ def kpi_report_regenerate(request, report_id): hospital=report.hospital, year=report.year, month=report.month, - generated_by=user + generated_by=user, ) - - messages.success(request, _('KPI Report regenerated successfully.')) - + + messages.success(request, _("KPI Report regenerated successfully.")) + except Exception as e: messages.error(request, str(e)) - - return redirect('analytics:kpi_report_detail', report_id=report.id) + + return redirect("analytics:kpi_report_detail", report_id=report.id) @login_required def kpi_report_pdf(request, report_id): """ Generate PDF version of KPI report - + Returns HTML page with print-friendly styling and html2pdf.js for client-side PDF generation. """ user = request.user - - report = get_object_or_404( - KPIReport.objects.select_related('hospital', 'generated_by'), - id=report_id - ) - + + report = get_object_or_404(KPIReport.objects.select_related("hospital", "generated_by"), id=report_id) + # Check permissions if not user.is_px_admin() and user.hospital != report.hospital: - messages.error(request, _('You do not have permission to view this report.')) - return redirect('analytics:kpi_report_list') - + messages.error(request, _("You do not have permission to view this report.")) + return redirect("analytics:kpi_report_list") + # Get monthly data (1-12) - monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by('month') + monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by("month") total_data = report.monthly_data.filter(month=0).first() - + # Build monthly data array ensuring 12 months monthly_data_dict = {m.month: m for m in monthly_data_qs} monthly_data = [monthly_data_dict.get(i) for i in range(1, 13)] - + # Get source breakdowns for pie chart source_breakdowns = report.source_breakdowns.all() source_chart_data = { - 'labels': [s.source_name for s in source_breakdowns] or ['No Data'], - 'data': [float(s.percentage) for s in source_breakdowns] or [100], + "labels": [s.source_name for s in source_breakdowns] or ["No Data"], + "data": [float(s.percentage) for s in source_breakdowns] or [100], } - + # Get department breakdowns department_breakdowns = report.department_breakdowns.all() - + + # Get location breakdowns + location_breakdowns = report.location_breakdowns.all() + # Prepare trend chart data - ensure we have 12 values trend_data_values = [] for m in monthly_data: @@ -372,25 +383,27 @@ def kpi_report_pdf(request, report_id): trend_data_values.append(float(m.percentage)) else: trend_data_values.append(0.0) - + trend_chart_data = { - 'labels': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], - 'data': trend_data_values, - 'target': float(report.target_percentage) if report.target_percentage else 95.0, + "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "data": trend_data_values, + "target": float(report.target_percentage) if report.target_percentage else 95.0, + "threshold": float(report.threshold_percentage) if report.threshold_percentage else 90.0, } - + context = { - 'report': report, - 'monthly_data': monthly_data, - 'total_data': total_data, - 'source_breakdowns': source_breakdowns, - 'department_breakdowns': department_breakdowns, - 'source_chart_data_json': json.dumps(source_chart_data), - 'trend_chart_data_json': json.dumps(trend_chart_data), - 'is_pdf': True, + "report": report, + "monthly_data": monthly_data, + "total_data": total_data, + "source_breakdowns": source_breakdowns, + "department_breakdowns": department_breakdowns, + "location_breakdowns": location_breakdowns, + "source_chart_data_json": json.dumps(source_chart_data), + "trend_chart_data_json": json.dumps(trend_chart_data), + "is_pdf": True, } - - return render(request, 'analytics/kpi_report_pdf.html', context) + + return render(request, "analytics/kpi_report_pdf.html", context) @login_required @@ -399,177 +412,171 @@ def kpi_report_api_data(request, report_id): API endpoint for KPI report data (for charts) """ user = request.user - + report = get_object_or_404(KPIReport, id=report_id) - + # Check permissions if not user.is_px_admin() and user.hospital != report.hospital: - return JsonResponse({'error': 'Permission denied'}, status=403) - + return JsonResponse({"error": "Permission denied"}, status=403) + # Get monthly data - monthly_data = report.monthly_data.filter(month__gt=0).order_by('month') - + monthly_data = report.monthly_data.filter(month__gt=0).order_by("month") + # Get source breakdowns source_breakdowns = report.source_breakdowns.all() - + data = { - 'report': { - 'id': str(report.id), - 'type': report.report_type, - 'type_display': report.get_report_type_display(), - 'year': report.year, - 'month': report.month, - 'kpi_id': report.kpi_id, - 'indicator_title': report.indicator_title, - 'target_percentage': float(report.target_percentage), - 'overall_result': float(report.overall_result), + "report": { + "id": str(report.id), + "type": report.report_type, + "type_display": report.get_report_type_display(), + "year": report.year, + "month": report.month, + "kpi_id": report.kpi_id, + "indicator_title": report.indicator_title, + "target_percentage": float(report.target_percentage), + "overall_result": float(report.overall_result), }, - 'monthly_data': [ + "monthly_data": [ { - 'month': m.month, - 'month_name': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][m.month - 1], - 'numerator': m.numerator, - 'denominator': m.denominator, - 'percentage': float(m.percentage), - 'is_below_target': m.is_below_target, + "month": m.month, + "month_name": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][ + m.month - 1 + ], + "numerator": m.numerator, + "denominator": m.denominator, + "percentage": float(m.percentage), + "is_below_target": m.is_below_target, } for m in monthly_data ], - 'source_breakdown': [ + "source_breakdown": [ { - 'source': s.source_name, - 'count': s.complaint_count, - 'percentage': float(s.percentage), + "source": s.source_name, + "count": s.complaint_count, + "percentage": float(s.percentage), } for s in source_breakdowns ], } - - return JsonResponse(data) + return JsonResponse(data) @login_required def kpi_report_ai_analysis(request, report_id): """ Generate or retrieve AI analysis for a KPI report. - + GET: Retrieve existing AI analysis POST: Generate new AI analysis """ from django.http import JsonResponse from .kpi_service import KPICalculationService - + user = request.user - report = get_object_or_404( - KPIReport.objects.select_related('hospital', 'generated_by'), - id=report_id - ) - + report = get_object_or_404(KPIReport.objects.select_related("hospital", "generated_by"), id=report_id) + # Check permissions if not user.is_px_admin() and user.hospital != report.hospital: - return JsonResponse({'error': 'Permission denied'}, status=403) - - if request.method == 'GET': + return JsonResponse({"error": "Permission denied"}, status=403) + + if request.method == "GET": # Return existing analysis if report.ai_analysis: - return JsonResponse({ - 'success': True, - 'analysis': report.ai_analysis, - 'generated_at': report.ai_analysis_generated_at.isoformat() if report.ai_analysis_generated_at else None - }) + return JsonResponse( + { + "success": True, + "analysis": report.ai_analysis, + "generated_at": report.ai_analysis_generated_at.isoformat() + if report.ai_analysis_generated_at + else None, + } + ) else: - return JsonResponse({ - 'success': False, - 'message': 'No AI analysis available. Use POST to generate.' - }, status=404) - - elif request.method == 'POST': + return JsonResponse( + {"success": False, "message": "No AI analysis available. Use POST to generate."}, status=404 + ) + + elif request.method == "POST": # Generate new analysis try: analysis = KPICalculationService.generate_ai_analysis(report) - - if 'error' in analysis: - return JsonResponse({ - 'success': False, - 'error': analysis['error'] - }, status=500) - - return JsonResponse({ - 'success': True, - 'analysis': analysis, - 'generated_at': report.ai_analysis_generated_at.isoformat() if report.ai_analysis_generated_at else None - }) - - except Exception as e: - return JsonResponse({ - 'success': False, - 'error': str(e) - }, status=500) - - return JsonResponse({'error': 'Method not allowed'}, status=405) + if "error" in analysis: + return JsonResponse({"success": False, "error": analysis["error"]}, status=500) + + return JsonResponse( + { + "success": True, + "analysis": analysis, + "generated_at": report.ai_analysis_generated_at.isoformat() + if report.ai_analysis_generated_at + else None, + } + ) + + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}, status=500) + + return JsonResponse({"error": "Method not allowed"}, status=405) @login_required def kpi_report_save_analysis(request, report_id): """ Save edited AI analysis for a KPI report. - + POST: Save edited analysis JSON """ import json from django.http import JsonResponse - + user = request.user report = get_object_or_404(KPIReport, id=report_id) - + # Check permissions - only PX admins and hospital admins can edit if not user.is_px_admin() and user.hospital != report.hospital: - return JsonResponse({'error': 'Permission denied'}, status=403) - - if request.method != 'POST': - return JsonResponse({'error': 'Method not allowed'}, status=405) - + return JsonResponse({"error": "Permission denied"}, status=403) + + if request.method != "POST": + return JsonResponse({"error": "Method not allowed"}, status=405) + try: # Parse the edited analysis from request body body = json.loads(request.body) - edited_analysis = body.get('analysis') - + edited_analysis = body.get("analysis") + if not edited_analysis: - return JsonResponse({'error': 'No analysis data provided'}, status=400) - + return JsonResponse({"error": "No analysis data provided"}, status=400) + # Preserve metadata if it exists - if report.ai_analysis and '_metadata' in report.ai_analysis: - edited_analysis['_metadata'] = report.ai_analysis['_metadata'] - edited_analysis['_metadata']['last_edited_at'] = timezone.now().isoformat() - edited_analysis['_metadata']['last_edited_by'] = user.get_full_name() or user.email + if report.ai_analysis and "_metadata" in report.ai_analysis: + edited_analysis["_metadata"] = report.ai_analysis["_metadata"] + edited_analysis["_metadata"]["last_edited_at"] = timezone.now().isoformat() + edited_analysis["_metadata"]["last_edited_by"] = user.get_full_name() or user.email else: - edited_analysis['_metadata'] = { - 'generated_at': timezone.now().isoformat(), - 'report_id': str(report.id), - 'report_type': report.report_type, - 'hospital': report.hospital.name, - 'year': report.year, - 'month': report.month, - 'last_edited_at': timezone.now().isoformat(), - 'last_edited_by': user.get_full_name() or user.email + edited_analysis["_metadata"] = { + "generated_at": timezone.now().isoformat(), + "report_id": str(report.id), + "report_type": report.report_type, + "hospital": report.hospital.name, + "year": report.year, + "month": report.month, + "last_edited_at": timezone.now().isoformat(), + "last_edited_by": user.get_full_name() or user.email, } - + # Save to report report.ai_analysis = edited_analysis - report.save(update_fields=['ai_analysis']) - + report.save(update_fields=["ai_analysis"]) + logger.info(f"AI analysis edited for KPI report {report.id} by user {user.id}") - - return JsonResponse({ - 'success': True, - 'message': 'Analysis saved successfully' - }) - + + return JsonResponse({"success": True, "message": "Analysis saved successfully"}) + except json.JSONDecodeError: - return JsonResponse({'error': 'Invalid JSON data'}, status=400) + return JsonResponse({"error": "Invalid JSON data"}, status=400) except Exception as e: logger.exception(f"Error saving AI analysis for report {report.id}: {e}") - return JsonResponse({'error': str(e)}, status=500) + return JsonResponse({"error": str(e)}, status=500) diff --git a/apps/analytics/services/analytics_service.py b/apps/analytics/services/analytics_service.py index b0c38b5..a633334 100644 --- a/apps/analytics/services/analytics_service.py +++ b/apps/analytics/services/analytics_service.py @@ -4,6 +4,7 @@ Unified Analytics Service Provides comprehensive analytics and metrics for the PX Command Center Dashboard. Consolidates data from complaints, surveys, actions, physicians, and other modules. """ + from datetime import datetime, timedelta from typing import Dict, List, Optional, Any @@ -26,7 +27,7 @@ from apps.analytics.models import KPI, KPIValue class UnifiedAnalyticsService: """ Unified service for all PX360 analytics and KPIs. - + Provides methods to retrieve: - All KPIs with filters - Chart data for various visualizations @@ -35,10 +36,10 @@ class UnifiedAnalyticsService: - Sentiment analysis metrics - SLA compliance data """ - + # Cache timeout (in seconds) - 5 minutes for most data CACHE_TIMEOUT = 300 - + @staticmethod def _get_cache_key(prefix: str, **kwargs) -> str: """Generate cache key based on parameters""" @@ -47,82 +48,82 @@ class UnifiedAnalyticsService: if value is not None: parts.append(f"{key}:{value}") return ":".join(parts) - + @staticmethod def _get_date_range(date_range: str, custom_start=None, custom_end=None) -> tuple: """ Get start and end dates based on date_range parameter. - + Args: date_range: '7d', '30d', '90d', 'this_month', 'last_month', 'quarter', 'year', or 'custom' custom_start: Custom start date (required if date_range='custom') custom_end: Custom end date (required if date_range='custom') - + Returns: tuple: (start_date, end_date) """ now = timezone.now() - - if date_range == 'custom' and custom_start and custom_end: + + if date_range == "custom" and custom_start and custom_end: return custom_start, custom_end - + date_ranges = { - '7d': timedelta(days=7), - '30d': timedelta(days=30), - '90d': timedelta(days=90), + "7d": timedelta(days=7), + "30d": timedelta(days=30), + "90d": timedelta(days=90), } - + if date_range in date_ranges: end_date = now start_date = now - date_ranges[date_range] return start_date, end_date - - elif date_range == 'this_month': + + elif date_range == "this_month": start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) end_date = now return start_date, end_date - - elif date_range == 'last_month': + + elif date_range == "last_month": if now.month == 1: - start_date = now.replace(year=now.year-1, month=12, day=1, hour=0, minute=0, second=0, microsecond=0) - end_date = now.replace(year=now.year-1, month=12, day=31, hour=23, minute=59, second=59) + start_date = now.replace(year=now.year - 1, month=12, day=1, hour=0, minute=0, second=0, microsecond=0) + end_date = now.replace(year=now.year - 1, month=12, day=31, hour=23, minute=59, second=59) else: - start_date = now.replace(month=now.month-1, day=1, hour=0, minute=0, second=0, microsecond=0) + start_date = now.replace(month=now.month - 1, day=1, hour=0, minute=0, second=0, microsecond=0) # Get last day of previous month next_month = now.replace(day=1) last_day = (next_month - timedelta(days=1)).day - end_date = now.replace(month=now.month-1, day=last_day, hour=23, minute=59, second=59) + end_date = now.replace(month=now.month - 1, day=last_day, hour=23, minute=59, second=59) return start_date, end_date - - elif date_range == 'quarter': + + elif date_range == "quarter": current_quarter = (now.month - 1) // 3 start_month = current_quarter * 3 + 1 start_date = now.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0) end_date = now return start_date, end_date - - elif date_range == 'year': + + elif date_range == "year": start_date = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) end_date = now return start_date, end_date - + # Default to 30 days return now - timedelta(days=30), now - + @staticmethod def _filter_by_role(queryset, user) -> Any: """ Filter queryset based on user role and permissions. - + Args: queryset: Django queryset user: User object - + Returns: Filtered queryset """ # Check if queryset has hospital/department fields - if hasattr(queryset.model, 'hospital'): + if hasattr(queryset.model, "hospital"): if user.is_px_admin(): pass # See all elif user.is_hospital_admin() and user.hospital: @@ -132,20 +133,20 @@ class UnifiedAnalyticsService: else: queryset = queryset.none() return queryset - + @staticmethod def get_all_kpis( user, - date_range: str = '30d', + date_range: str = "30d", hospital_id: Optional[str] = None, department_id: Optional[str] = None, kpi_category: Optional[str] = None, custom_start: Optional[datetime] = None, - custom_end: Optional[datetime] = None + custom_end: Optional[datetime] = None, ) -> Dict[str, Any]: """ Get all KPIs with applied filters. - + Args: user: Current user date_range: Date range filter @@ -154,80 +155,71 @@ class UnifiedAnalyticsService: kpi_category: Optional KPI category filter custom_start: Custom start date custom_end: Custom end date - + Returns: dict: All KPI values """ - start_date, end_date = UnifiedAnalyticsService._get_date_range( - date_range, custom_start, custom_end - ) - + start_date, end_date = UnifiedAnalyticsService._get_date_range(date_range, custom_start, custom_end) + cache_key = UnifiedAnalyticsService._get_cache_key( - 'all_kpis', + "all_kpis", user_id=user.id, date_range=date_range, hospital_id=hospital_id, department_id=department_id, - kpi_category=kpi_category + kpi_category=kpi_category, ) - + cached_data = cache.get(cache_key) if cached_data: return cached_data - + # Get base querysets with role filtering - complaints_qs = UnifiedAnalyticsService._filter_by_role( - Complaint.objects.all(), user - ).filter(created_at__gte=start_date, created_at__lte=end_date) - - actions_qs = UnifiedAnalyticsService._filter_by_role( - PXAction.objects.all(), user - ).filter(created_at__gte=start_date, created_at__lte=end_date) - - surveys_qs = UnifiedAnalyticsService._filter_by_role( - SurveyInstance.objects.all(), user - ).filter( - completed_at__gte=start_date, - completed_at__lte=end_date, - status='completed' + complaints_qs = UnifiedAnalyticsService._filter_by_role(Complaint.objects.all(), user).filter( + created_at__gte=start_date, created_at__lte=end_date ) - + + actions_qs = UnifiedAnalyticsService._filter_by_role(PXAction.objects.all(), user).filter( + created_at__gte=start_date, created_at__lte=end_date + ) + + surveys_qs = UnifiedAnalyticsService._filter_by_role(SurveyInstance.objects.all(), user).filter( + completed_at__gte=start_date, completed_at__lte=end_date, status="completed" + ) + # Apply additional filters if hospital_id: hospital = Hospital.objects.filter(id=hospital_id).first() if hospital: complaints_qs = complaints_qs.filter(hospital=hospital) actions_qs = actions_qs.filter(hospital=hospital) - surveys_qs = surveys_qs.filter(survey_template__hospital=hospital) - + surveys_qs = surveys_qs.filter(hospital=hospital) + if department_id: department = Department.objects.filter(id=department_id).first() if department: complaints_qs = complaints_qs.filter(department=department) actions_qs = actions_qs.filter(department=department) surveys_qs = surveys_qs.filter(journey_stage_instance__department=department) - + # Calculate KPIs kpis = { # Complaints KPIs - 'total_complaints': int(complaints_qs.count()), - 'open_complaints': int(complaints_qs.filter(status__in=['open', 'in_progress']).count()), - 'overdue_complaints': int(complaints_qs.filter(is_overdue=True).count()), - 'high_severity_complaints': int(complaints_qs.filter(severity__in=['high', 'critical']).count()), - 'resolved_complaints': int(complaints_qs.filter(status__in=['resolved', 'closed']).count()), - + "total_complaints": int(complaints_qs.count()), + "open_complaints": int(complaints_qs.filter(status__in=["open", "in_progress"]).count()), + "overdue_complaints": int(complaints_qs.filter(is_overdue=True).count()), + "high_severity_complaints": int(complaints_qs.filter(severity__in=["high", "critical"]).count()), + "resolved_complaints": int(complaints_qs.filter(status__in=["resolved", "closed"]).count()), # Actions KPIs - 'total_actions': int(actions_qs.count()), - 'open_actions': int(actions_qs.filter(status__in=['open', 'in_progress']).count()), - 'overdue_actions': int(actions_qs.filter(is_overdue=True).count()), - 'escalated_actions': int(actions_qs.filter(escalation_level__gt=0).count()), - 'resolved_actions': int(actions_qs.filter(status='completed').count()), - + "total_actions": int(actions_qs.count()), + "open_actions": int(actions_qs.filter(status__in=["open", "in_progress"]).count()), + "overdue_actions": int(actions_qs.filter(is_overdue=True).count()), + "escalated_actions": int(actions_qs.filter(escalation_level__gt=0).count()), + "resolved_actions": int(actions_qs.filter(status="completed").count()), # Survey KPIs - 'total_surveys': int(surveys_qs.count()), - 'negative_surveys': int(surveys_qs.filter(is_negative=True).count()), - 'avg_survey_score': float(surveys_qs.aggregate(avg=Avg('total_score'))['avg'] or 0), - + "total_surveys": int(surveys_qs.count()), + "negative_surveys": int(surveys_qs.filter(is_negative=True).count()), + "avg_survey_score": float(surveys_qs.aggregate(avg=Avg("total_score"))["avg"] or 0), # Social Media KPIs # Sentiment is stored in ai_analysis JSON field as ai_analysis.sentiment 'negative_social_comments': int(SocialComment.objects.filter( @@ -237,60 +229,52 @@ class UnifiedAnalyticsService: ).count()), # Call Center KPIs - 'low_call_ratings': int(CallCenterInteraction.objects.filter( - is_low_rating=True, - call_started_at__gte=start_date, - call_started_at__lte=end_date - ).count()), - + "low_call_ratings": int( + CallCenterInteraction.objects.filter( + is_low_rating=True, call_started_at__gte=start_date, call_started_at__lte=end_date + ).count() + ), # Sentiment KPIs - 'total_sentiment_analyses': int(SentimentResult.objects.filter( - created_at__gte=start_date, - created_at__lte=end_date - ).count()), + "total_sentiment_analyses": int( + SentimentResult.objects.filter(created_at__gte=start_date, created_at__lte=end_date).count() + ), } - + # Add trends (compare with previous period) - prev_start, prev_end = UnifiedAnalyticsService._get_date_range( - date_range, custom_start, custom_end - ) + prev_start, prev_end = UnifiedAnalyticsService._get_date_range(date_range, custom_start, custom_end) # Shift back by same duration duration = end_date - start_date prev_start = start_date - duration prev_end = end_date - duration - - prev_complaints = int(complaints_qs.filter( - created_at__gte=prev_start, - created_at__lte=prev_end - ).count()) - - kpis['complaints_trend'] = { - 'current': kpis['total_complaints'], - 'previous': prev_complaints, - 'percentage_change': float( - ((kpis['total_complaints'] - prev_complaints) / prev_complaints * 100) - if prev_complaints > 0 else 0 - ) + + prev_complaints = int(complaints_qs.filter(created_at__gte=prev_start, created_at__lte=prev_end).count()) + + kpis["complaints_trend"] = { + "current": kpis["total_complaints"], + "previous": prev_complaints, + "percentage_change": float( + ((kpis["total_complaints"] - prev_complaints) / prev_complaints * 100) if prev_complaints > 0 else 0 + ), } - + # Cache the results cache.set(cache_key, kpis, UnifiedAnalyticsService.CACHE_TIMEOUT) - + return kpis - + @staticmethod def get_chart_data( user, chart_type: str, - date_range: str = '30d', + date_range: str = "30d", hospital_id: Optional[str] = None, department_id: Optional[str] = None, custom_start: Optional[datetime] = None, - custom_end: Optional[datetime] = None + custom_end: Optional[datetime] = None, ) -> Dict[str, Any]: """ Get data for specific chart types. - + Args: user: Current user chart_type: Type of chart ('complaints_trend', 'sla_compliance', 'survey_satisfaction', etc.) @@ -299,95 +283,85 @@ class UnifiedAnalyticsService: department_id: Optional department filter custom_start: Custom start date custom_end: Custom end date - + Returns: dict: Chart data in format suitable for ApexCharts """ - start_date, end_date = UnifiedAnalyticsService._get_date_range( - date_range, custom_start, custom_end - ) - + start_date, end_date = UnifiedAnalyticsService._get_date_range(date_range, custom_start, custom_end) + cache_key = UnifiedAnalyticsService._get_cache_key( - f'chart_{chart_type}', + f"chart_{chart_type}", user_id=user.id, date_range=date_range, hospital_id=hospital_id, - department_id=department_id + department_id=department_id, ) - + cached_data = cache.get(cache_key) if cached_data: return cached_data - + # Get base complaint queryset - complaints_qs = UnifiedAnalyticsService._filter_by_role( - Complaint.objects.all(), user - ).filter(created_at__gte=start_date, created_at__lte=end_date) - - surveys_qs = UnifiedAnalyticsService._filter_by_role( - SurveyInstance.objects.all(), user - ).filter( - completed_at__gte=start_date, - completed_at__lte=end_date, - status='completed' + complaints_qs = UnifiedAnalyticsService._filter_by_role(Complaint.objects.all(), user).filter( + created_at__gte=start_date, created_at__lte=end_date ) - + + surveys_qs = UnifiedAnalyticsService._filter_by_role(SurveyInstance.objects.all(), user).filter( + completed_at__gte=start_date, completed_at__lte=end_date, status="completed" + ) + # Apply filters if hospital_id: complaints_qs = complaints_qs.filter(hospital_id=hospital_id) - surveys_qs = surveys_qs.filter(survey_template__hospital_id=hospital_id) - + surveys_qs = surveys_qs.filter(hospital_id=hospital_id) + if department_id: complaints_qs = complaints_qs.filter(department_id=department_id) surveys_qs = surveys_qs.filter(journey_stage_instance__department_id=department_id) - - if chart_type == 'complaints_trend': + + if chart_type == "complaints_trend": data = UnifiedAnalyticsService._get_complaints_trend(complaints_qs, start_date, end_date) - - elif chart_type == 'complaints_by_category': + + elif chart_type == "complaints_by_category": data = UnifiedAnalyticsService._get_complaints_by_category(complaints_qs) - - elif chart_type == 'complaints_by_severity': + + elif chart_type == "complaints_by_severity": data = UnifiedAnalyticsService._get_complaints_by_severity(complaints_qs) - - elif chart_type == 'sla_compliance': + + elif chart_type == "sla_compliance": data = ComplaintAnalytics.get_sla_compliance( - hospital_id and Hospital.objects.filter(id=hospital_id).first(), - days=(end_date - start_date).days + hospital_id and Hospital.objects.filter(id=hospital_id).first(), days=(end_date - start_date).days ) - - elif chart_type == 'resolution_rate': + + elif chart_type == "resolution_rate": data = ComplaintAnalytics.get_resolution_rate( - hospital_id and Hospital.objects.filter(id=hospital_id).first(), - days=(end_date - start_date).days + hospital_id and Hospital.objects.filter(id=hospital_id).first(), days=(end_date - start_date).days ) - - elif chart_type == 'survey_satisfaction_trend': + + elif chart_type == "survey_satisfaction_trend": data = UnifiedAnalyticsService._get_survey_satisfaction_trend(surveys_qs, start_date, end_date) - - elif chart_type == 'survey_distribution': + + elif chart_type == "survey_distribution": data = UnifiedAnalyticsService._get_survey_distribution(surveys_qs) - - elif chart_type == 'sentiment_distribution': + + elif chart_type == "sentiment_distribution": data = UnifiedAnalyticsService._get_sentiment_distribution(start_date, end_date) - - elif chart_type == 'department_performance': - data = UnifiedAnalyticsService._get_department_performance( - user, start_date, end_date, hospital_id - ) - - elif chart_type == 'physician_leaderboard': + + elif chart_type == "department_performance": + data = UnifiedAnalyticsService._get_department_performance(user, start_date, end_date, hospital_id) + + elif chart_type == "physician_leaderboard": data = UnifiedAnalyticsService._get_physician_leaderboard( user, start_date, end_date, hospital_id, department_id, limit=10 ) - + else: - data = {'error': f'Unknown chart type: {chart_type}'} - + data = {"error": f"Unknown chart type: {chart_type}"} + cache.set(cache_key, data, UnifiedAnalyticsService.CACHE_TIMEOUT) - + return data - + @staticmethod def _get_complaints_trend(queryset, start_date, end_date) -> Dict[str, Any]: """Get complaints trend over time (grouped by day)""" @@ -395,55 +369,40 @@ class UnifiedAnalyticsService: current_date = start_date while current_date <= end_date: next_date = current_date + timedelta(days=1) - count = queryset.filter( - created_at__gte=current_date, - created_at__lt=next_date - ).count() - data.append({ - 'date': current_date.strftime('%Y-%m-%d'), - 'count': count - }) + count = queryset.filter(created_at__gte=current_date, created_at__lt=next_date).count() + data.append({"date": current_date.strftime("%Y-%m-%d"), "count": count}) current_date = next_date - + return { - 'type': 'line', - 'labels': [d['date'] for d in data], - 'series': [{'name': 'Complaints', 'data': [d['count'] for d in data]}] + "type": "line", + "labels": [d["date"] for d in data], + "series": [{"name": "Complaints", "data": [d["count"] for d in data]}], } - + @staticmethod def _get_complaints_by_category(queryset) -> Dict[str, Any]: """Get complaints breakdown by category""" - categories = queryset.values('category').annotate( - count=Count('id') - ).order_by('-count') - + categories = queryset.values("category").annotate(count=Count("id")).order_by("-count") + return { - 'type': 'donut', - 'labels': [c['category'] or 'Uncategorized' for c in categories], - 'series': [c['count'] for c in categories] + "type": "donut", + "labels": [c["category"] or "Uncategorized" for c in categories], + "series": [c["count"] for c in categories], } - + @staticmethod def _get_complaints_by_severity(queryset) -> Dict[str, Any]: """Get complaints breakdown by severity""" - severity_counts = queryset.values('severity').annotate( - count=Count('id') - ).order_by('-count') - - severity_labels = { - 'low': 'Low', - 'medium': 'Medium', - 'high': 'High', - 'critical': 'Critical' - } - + severity_counts = queryset.values("severity").annotate(count=Count("id")).order_by("-count") + + severity_labels = {"low": "Low", "medium": "Medium", "high": "High", "critical": "Critical"} + return { - 'type': 'pie', - 'labels': [severity_labels.get(s['severity'], s['severity']) for s in severity_counts], - 'series': [s['count'] for s in severity_counts] + "type": "pie", + "labels": [severity_labels.get(s["severity"], s["severity"]) for s in severity_counts], + "series": [s["count"] for s in severity_counts], } - + @staticmethod def _get_survey_satisfaction_trend(queryset, start_date, end_date) -> Dict[str, Any]: """Get survey satisfaction trend over time""" @@ -451,56 +410,50 @@ class UnifiedAnalyticsService: current_date = start_date while current_date <= end_date: next_date = current_date + timedelta(days=1) - avg_score = queryset.filter( - completed_at__gte=current_date, - completed_at__lt=next_date - ).aggregate(avg=Avg('total_score'))['avg'] or 0 - data.append({ - 'date': current_date.strftime('%Y-%m-%d'), - 'score': round(avg_score, 2) - }) + avg_score = ( + queryset.filter(completed_at__gte=current_date, completed_at__lt=next_date).aggregate( + avg=Avg("total_score") + )["avg"] + or 0 + ) + data.append({"date": current_date.strftime("%Y-%m-%d"), "score": round(avg_score, 2)}) current_date = next_date - + return { - 'type': 'line', - 'labels': [d['date'] for d in data], - 'series': [{'name': 'Satisfaction', 'data': [d['score'] for d in data]}] + "type": "line", + "labels": [d["date"] for d in data], + "series": [{"name": "Satisfaction", "data": [d["score"] for d in data]}], } - + @staticmethod def _get_survey_distribution(queryset) -> Dict[str, Any]: """Get survey distribution by satisfaction level""" distribution = { - 'excellent': queryset.filter(total_score__gte=4.5).count(), - 'good': queryset.filter(total_score__gte=3.5, total_score__lt=4.5).count(), - 'average': queryset.filter(total_score__gte=2.5, total_score__lt=3.5).count(), - 'poor': queryset.filter(total_score__lt=2.5).count(), + "excellent": queryset.filter(total_score__gte=4.5).count(), + "good": queryset.filter(total_score__gte=3.5, total_score__lt=4.5).count(), + "average": queryset.filter(total_score__gte=2.5, total_score__lt=3.5).count(), + "poor": queryset.filter(total_score__lt=2.5).count(), } - + return { - 'type': 'donut', - 'labels': ['Excellent', 'Good', 'Average', 'Poor'], - 'series': [ - distribution['excellent'], - distribution['good'], - distribution['average'], - distribution['poor'] - ] + "type": "donut", + "labels": ["Excellent", "Good", "Average", "Poor"], + "series": [distribution["excellent"], distribution["good"], distribution["average"], distribution["poor"]], } - + @staticmethod def get_staff_performance_metrics( user, - date_range: str = '30d', + date_range: str = "30d", hospital_id: Optional[str] = None, department_id: Optional[str] = None, staff_ids: Optional[List[str]] = None, custom_start: Optional[datetime] = None, - custom_end: Optional[datetime] = None + custom_end: Optional[datetime] = None, ) -> Dict[str, Any]: """ Get performance metrics for staff members. - + Args: user: Current user date_range: Date range filter @@ -509,110 +462,114 @@ class UnifiedAnalyticsService: staff_ids: Optional list of specific staff IDs to evaluate custom_start: Custom start date custom_end: Custom end date - + Returns: dict: Staff performance metrics with complaints and inquiries data """ from apps.accounts.models import User - - start_date, end_date = UnifiedAnalyticsService._get_date_range( - date_range, custom_start, custom_end - ) - + + start_date, end_date = UnifiedAnalyticsService._get_date_range(date_range, custom_start, custom_end) + # Get staff queryset staff_qs = User.objects.all() - + # Filter by role if not user.is_px_admin() and user.hospital: staff_qs = staff_qs.filter(hospital=user.hospital) - + # Apply filters if hospital_id: staff_qs = staff_qs.filter(hospital_id=hospital_id) - + if department_id: staff_qs = staff_qs.filter(department_id=department_id) - + if staff_ids: staff_qs = staff_qs.filter(id__in=staff_ids) - + # Only staff with assigned complaints or inquiries - staff_qs = staff_qs.filter( - Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False) - ).distinct().prefetch_related('assigned_complaints', 'assigned_inquiries') - + staff_qs = ( + staff_qs.filter(Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False)) + .distinct() + .prefetch_related("assigned_complaints", "assigned_inquiries") + ) + staff_metrics = [] - + for staff_member in staff_qs: # Get complaints assigned to this staff complaints = Complaint.objects.filter( - assigned_to=staff_member, - created_at__gte=start_date, - created_at__lte=end_date + assigned_to=staff_member, created_at__gte=start_date, created_at__lte=end_date ) - + # Get inquiries assigned to this staff inquiries = Inquiry.objects.filter( - assigned_to=staff_member, - created_at__gte=start_date, - created_at__lte=end_date + assigned_to=staff_member, created_at__gte=start_date, created_at__lte=end_date ) - + # Calculate complaint metrics complaint_metrics = UnifiedAnalyticsService._calculate_complaint_metrics(complaints) - + # Calculate inquiry metrics inquiry_metrics = UnifiedAnalyticsService._calculate_inquiry_metrics(inquiries) - - staff_metrics.append({ - 'id': str(staff_member.id), - 'name': f"{staff_member.first_name} {staff_member.last_name}", - 'email': staff_member.email, - 'hospital': staff_member.hospital.name if staff_member.hospital else None, - 'department': staff_member.department.name if staff_member.department else None, - 'complaints': complaint_metrics, - 'inquiries': inquiry_metrics - }) - + + staff_metrics.append( + { + "id": str(staff_member.id), + "name": f"{staff_member.first_name} {staff_member.last_name}", + "email": staff_member.email, + "hospital": staff_member.hospital.name if staff_member.hospital else None, + "department": staff_member.department.name if staff_member.department else None, + "complaints": complaint_metrics, + "inquiries": inquiry_metrics, + } + ) + return { - 'staff_metrics': staff_metrics, - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat(), - 'date_range': date_range + "staff_metrics": staff_metrics, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "date_range": date_range, } - + @staticmethod def _calculate_complaint_metrics(complaints_qs) -> Dict[str, Any]: """Calculate detailed metrics for complaints""" total = complaints_qs.count() - + if total == 0: return { - 'total': 0, - 'internal': 0, - 'external': 0, - 'status': {'open': 0, 'in_progress': 0, 'resolved': 0, 'closed': 0}, - 'activation_time': {'within_2h': 0, 'more_than_2h': 0, 'not_assigned': 0}, - 'response_time': {'within_24h': 0, 'within_48h': 0, 'within_72h': 0, 'more_than_72h': 0, 'not_responded': 0} + "total": 0, + "internal": 0, + "external": 0, + "status": {"open": 0, "in_progress": 0, "resolved": 0, "closed": 0}, + "activation_time": {"within_2h": 0, "more_than_2h": 0, "not_assigned": 0}, + "response_time": { + "within_24h": 0, + "within_48h": 0, + "within_72h": 0, + "more_than_72h": 0, + "not_responded": 0, + }, } - + # Source breakdown - internal_count = complaints_qs.filter(source__name_en='staff').count() + internal_count = complaints_qs.filter(source__name_en="staff").count() external_count = total - internal_count - + # Status breakdown status_counts = { - 'open': complaints_qs.filter(status='open').count(), - 'in_progress': complaints_qs.filter(status='in_progress').count(), - 'resolved': complaints_qs.filter(status='resolved').count(), - 'closed': complaints_qs.filter(status='closed').count() + "open": complaints_qs.filter(status="open").count(), + "in_progress": complaints_qs.filter(status="in_progress").count(), + "resolved": complaints_qs.filter(status="resolved").count(), + "closed": complaints_qs.filter(status="closed").count(), } - + # Activation time (assigned_at - created_at) activation_within_2h = 0 activation_more_than_2h = 0 not_assigned = 0 - + for complaint in complaints_qs: if complaint.assigned_at: activation_time = (complaint.assigned_at - complaint.created_at).total_seconds() @@ -622,14 +579,14 @@ class UnifiedAnalyticsService: activation_more_than_2h += 1 else: not_assigned += 1 - + # Response time (time to first update) response_within_24h = 0 response_within_48h = 0 response_within_72h = 0 response_more_than_72h = 0 not_responded = 0 - + for complaint in complaints_qs: first_update = complaint.updates.first() if first_update: @@ -644,53 +601,59 @@ class UnifiedAnalyticsService: response_more_than_72h += 1 else: not_responded += 1 - + return { - 'total': total, - 'internal': internal_count, - 'external': external_count, - 'status': status_counts, - 'activation_time': { - 'within_2h': activation_within_2h, - 'more_than_2h': activation_more_than_2h, - 'not_assigned': not_assigned + "total": total, + "internal": internal_count, + "external": external_count, + "status": status_counts, + "activation_time": { + "within_2h": activation_within_2h, + "more_than_2h": activation_more_than_2h, + "not_assigned": not_assigned, + }, + "response_time": { + "within_24h": response_within_24h, + "within_48h": response_within_48h, + "within_72h": response_within_72h, + "more_than_72h": response_more_than_72h, + "not_responded": not_responded, }, - 'response_time': { - 'within_24h': response_within_24h, - 'within_48h': response_within_48h, - 'within_72h': response_within_72h, - 'more_than_72h': response_more_than_72h, - 'not_responded': not_responded - } } - + @staticmethod def _calculate_inquiry_metrics(inquiries_qs) -> Dict[str, Any]: """Calculate detailed metrics for inquiries""" total = inquiries_qs.count() - + if total == 0: return { - 'total': 0, - 'status': {'open': 0, 'in_progress': 0, 'resolved': 0, 'closed': 0}, - 'response_time': {'within_24h': 0, 'within_48h': 0, 'within_72h': 0, 'more_than_72h': 0, 'not_responded': 0} + "total": 0, + "status": {"open": 0, "in_progress": 0, "resolved": 0, "closed": 0}, + "response_time": { + "within_24h": 0, + "within_48h": 0, + "within_72h": 0, + "more_than_72h": 0, + "not_responded": 0, + }, } - + # Status breakdown status_counts = { - 'open': inquiries_qs.filter(status='open').count(), - 'in_progress': inquiries_qs.filter(status='in_progress').count(), - 'resolved': inquiries_qs.filter(status='resolved').count(), - 'closed': inquiries_qs.filter(status='closed').count() + "open": inquiries_qs.filter(status="open").count(), + "in_progress": inquiries_qs.filter(status="in_progress").count(), + "resolved": inquiries_qs.filter(status="resolved").count(), + "closed": inquiries_qs.filter(status="closed").count(), } - + # Response time (responded_at - created_at) response_within_24h = 0 response_within_48h = 0 response_within_72h = 0 response_more_than_72h = 0 not_responded = 0 - + for inquiry in inquiries_qs: if inquiry.responded_at: response_time = (inquiry.responded_at - inquiry.created_at).total_seconds() @@ -704,403 +667,388 @@ class UnifiedAnalyticsService: response_more_than_72h += 1 else: not_responded += 1 - + return { - 'total': total, - 'status': status_counts, - 'response_time': { - 'within_24h': response_within_24h, - 'within_48h': response_within_48h, - 'within_72h': response_within_72h, - 'more_than_72h': response_more_than_72h, - 'not_responded': not_responded - } + "total": total, + "status": status_counts, + "response_time": { + "within_24h": response_within_24h, + "within_48h": response_within_48h, + "within_72h": response_within_72h, + "more_than_72h": response_more_than_72h, + "not_responded": not_responded, + }, } - + @staticmethod def _get_sentiment_distribution(start_date, end_date) -> Dict[str, Any]: """Get sentiment analysis distribution""" - queryset = SentimentResult.objects.filter( - created_at__gte=start_date, - created_at__lte=end_date - ) - - distribution = queryset.values('sentiment').annotate( - count=Count('id') - ) - - sentiment_labels = { - 'positive': 'Positive', - 'neutral': 'Neutral', - 'negative': 'Negative' - } - - sentiment_order = ['positive', 'neutral', 'negative'] - + queryset = SentimentResult.objects.filter(created_at__gte=start_date, created_at__lte=end_date) + + distribution = queryset.values("sentiment").annotate(count=Count("id")) + + sentiment_labels = {"positive": "Positive", "neutral": "Neutral", "negative": "Negative"} + + sentiment_order = ["positive", "neutral", "negative"] + return { - 'type': 'donut', - 'labels': [sentiment_labels.get(s['sentiment'], s['sentiment']) for s in distribution], - 'series': [s['count'] for s in distribution] + "type": "donut", + "labels": [sentiment_labels.get(s["sentiment"], s["sentiment"]) for s in distribution], + "series": [s["count"] for s in distribution], } - + @staticmethod - def _get_department_performance( - user, start_date, end_date, hospital_id: Optional[str] = None - ) -> Dict[str, Any]: + def _get_department_performance(user, start_date, end_date, hospital_id: Optional[str] = None) -> Dict[str, Any]: """Get department performance rankings""" - queryset = Department.objects.filter(status='active') - + queryset = Department.objects.filter(status="active") + if hospital_id: queryset = queryset.filter(hospital_id=hospital_id) elif not user.is_px_admin() and user.hospital: queryset = queryset.filter(hospital=user.hospital) - + # Annotate with survey data # SurveyInstance links to PatientJourneyInstance which has department field - departments = queryset.annotate( - avg_survey_score=Avg( - 'journey_instances__surveys__total_score', - filter=Q(journey_instances__surveys__status='completed', + departments = ( + queryset.annotate( + avg_survey_score=Avg( + "journey_instances__surveys__total_score", + filter=Q( + journey_instances__surveys__status="completed", journey_instances__surveys__completed_at__gte=start_date, - journey_instances__surveys__completed_at__lte=end_date) - ), - survey_count=Count( - 'journey_instances__surveys', - filter=Q(journey_instances__surveys__status='completed', + journey_instances__surveys__completed_at__lte=end_date, + ), + ), + survey_count=Count( + "journey_instances__surveys", + filter=Q( + journey_instances__surveys__status="completed", journey_instances__surveys__completed_at__gte=start_date, - journey_instances__surveys__completed_at__lte=end_date) + journey_instances__surveys__completed_at__lte=end_date, + ), + ), ) - ).filter(survey_count__gt=0).order_by('-avg_survey_score')[:10] - + .filter(survey_count__gt=0) + .order_by("-avg_survey_score")[:10] + ) + return { - 'type': 'bar', - 'labels': [d.name for d in departments], - 'series': [{ - 'name': 'Average Score', - 'data': [round(d.avg_survey_score or 0, 2) for d in departments] - }] + "type": "bar", + "labels": [d.name for d in departments], + "series": [{"name": "Average Score", "data": [round(d.avg_survey_score or 0, 2) for d in departments]}], } - + @staticmethod def _get_physician_leaderboard( - user, start_date, end_date, hospital_id: Optional[str] = None, - department_id: Optional[str] = None, limit: int = 10 + user, + start_date, + end_date, + hospital_id: Optional[str] = None, + department_id: Optional[str] = None, + limit: int = 10, ) -> Dict[str, Any]: """Get physician leaderboard for the current period""" now = timezone.now() - queryset = PhysicianMonthlyRating.objects.filter( - year=now.year, - month=now.month - ).select_related('staff', 'staff__hospital', 'staff__department') - + queryset = PhysicianMonthlyRating.objects.filter(year=now.year, month=now.month).select_related( + "staff", "staff__hospital", "staff__department" + ) + # Apply RBAC filters if not user.is_px_admin() and user.hospital: queryset = queryset.filter(staff__hospital=user.hospital) - + if hospital_id: queryset = queryset.filter(staff__hospital_id=hospital_id) - + if department_id: queryset = queryset.filter(staff__department_id=department_id) - - queryset = queryset.order_by('-average_rating')[:limit] - + + queryset = queryset.order_by("-average_rating")[:limit] + return { - 'type': 'bar', - 'labels': [f"{r.staff.first_name} {r.staff.last_name}" for r in queryset], - 'series': [{ - 'name': 'Rating', - 'data': [float(round(r.average_rating, 2)) for r in queryset] - }], - 'metadata': [ + "type": "bar", + "labels": [f"{r.staff.first_name} {r.staff.last_name}" for r in queryset], + "series": [{"name": "Rating", "data": [float(round(r.average_rating, 2)) for r in queryset]}], + "metadata": [ { - 'name': f"{r.staff.first_name} {r.staff.last_name}", - 'physician_id': str(r.staff.id), - 'specialization': r.staff.specialization, - 'department': r.staff.department.name if r.staff.department else None, - 'rating': float(round(r.average_rating, 2)), - 'surveys': int(r.total_surveys) if r.total_surveys is not None else 0, - 'positive': int(r.positive_count) if r.positive_count is not None else 0, - 'neutral': int(r.neutral_count) if r.neutral_count is not None else 0, - 'negative': int(r.negative_count) if r.negative_count is not None else 0 + "name": f"{r.staff.first_name} {r.staff.last_name}", + "physician_id": str(r.staff.id), + "specialization": r.staff.specialization, + "department": r.staff.department.name if r.staff.department else None, + "rating": float(round(r.average_rating, 2)), + "surveys": int(r.total_surveys) if r.total_surveys is not None else 0, + "positive": int(r.positive_count) if r.positive_count is not None else 0, + "neutral": int(r.neutral_count) if r.neutral_count is not None else 0, + "negative": int(r.negative_count) if r.negative_count is not None else 0, } for r in queryset - ] + ], } - # ============================================================================ # ENHANCED ADMIN EVALUATION - Staff Performance Analytics # ============================================================================ - + @staticmethod def get_staff_detailed_performance( staff_id: str, user, - date_range: str = '30d', + date_range: str = "30d", custom_start: Optional[datetime] = None, - custom_end: Optional[datetime] = None + custom_end: Optional[datetime] = None, ) -> Dict[str, Any]: """ Get detailed performance metrics for a single staff member. - + Args: staff_id: Staff member UUID user: Current user (for permission checking) date_range: Date range filter custom_start: Custom start date custom_end: Custom end date - + Returns: dict: Detailed performance metrics with timeline """ from apps.accounts.models import User - - start_date, end_date = UnifiedAnalyticsService._get_date_range( - date_range, custom_start, custom_end - ) - - staff = User.objects.select_related('hospital', 'department').get(id=staff_id) - + + start_date, end_date = UnifiedAnalyticsService._get_date_range(date_range, custom_start, custom_end) + + staff = User.objects.select_related("hospital", "department").get(id=staff_id) + # Check permissions if not user.is_px_admin(): if user.hospital and staff.hospital != user.hospital: raise PermissionError("Cannot view staff from other hospitals") - + # Get complaints with timeline complaints = Complaint.objects.filter( - assigned_to=staff, - created_at__gte=start_date, - created_at__lte=end_date - ).order_by('created_at') - + assigned_to=staff, created_at__gte=start_date, created_at__lte=end_date + ).order_by("created_at") + # Get inquiries with timeline inquiries = Inquiry.objects.filter( - assigned_to=staff, - created_at__gte=start_date, - created_at__lte=end_date - ).order_by('created_at') - + assigned_to=staff, created_at__gte=start_date, created_at__lte=end_date + ).order_by("created_at") + # Calculate daily workload for trend daily_stats = {} current = start_date.date() end = end_date.date() - + while current <= end: daily_stats[current.isoformat()] = { - 'complaints_created': 0, - 'complaints_resolved': 0, - 'inquiries_created': 0, - 'inquiries_resolved': 0 + "complaints_created": 0, + "complaints_resolved": 0, + "inquiries_created": 0, + "inquiries_resolved": 0, } current += timedelta(days=1) - + for c in complaints: date_key = c.created_at.date().isoformat() if date_key in daily_stats: - daily_stats[date_key]['complaints_created'] += 1 - if c.status in ['resolved', 'closed'] and c.resolved_at: + daily_stats[date_key]["complaints_created"] += 1 + if c.status in ["resolved", "closed"] and c.resolved_at: resolve_key = c.resolved_at.date().isoformat() if resolve_key in daily_stats: - daily_stats[resolve_key]['complaints_resolved'] += 1 - + daily_stats[resolve_key]["complaints_resolved"] += 1 + for i in inquiries: date_key = i.created_at.date().isoformat() if date_key in daily_stats: - daily_stats[date_key]['inquiries_created'] += 1 - if i.status in ['resolved', 'closed'] and i.responded_at: + daily_stats[date_key]["inquiries_created"] += 1 + if i.status in ["resolved", "closed"] and i.responded_at: respond_key = i.responded_at.date().isoformat() if respond_key in daily_stats: - daily_stats[respond_key]['inquiries_resolved'] += 1 - + daily_stats[respond_key]["inquiries_resolved"] += 1 + # Calculate performance score (0-100) complaint_metrics = UnifiedAnalyticsService._calculate_complaint_metrics(complaints) inquiry_metrics = UnifiedAnalyticsService._calculate_inquiry_metrics(inquiries) - - performance_score = UnifiedAnalyticsService._calculate_performance_score( - complaint_metrics, inquiry_metrics - ) - + + performance_score = UnifiedAnalyticsService._calculate_performance_score(complaint_metrics, inquiry_metrics) + # Get recent items - recent_complaints = complaints.select_related('patient', 'hospital').order_by('-created_at')[:10] - recent_inquiries = inquiries.select_related('patient', 'hospital').order_by('-created_at')[:10] - + recent_complaints = complaints.select_related("patient", "hospital").order_by("-created_at")[:10] + recent_inquiries = inquiries.select_related("patient", "hospital").order_by("-created_at")[:10] + return { - 'staff': { - 'id': str(staff.id), - 'name': f"{staff.first_name} {staff.last_name}", - 'email': staff.email, - 'hospital': staff.hospital.name if staff.hospital else None, - 'department': staff.department.name if staff.department else None, - 'role': staff.get_role_names()[0] if staff.get_role_names() else 'Staff' + "staff": { + "id": str(staff.id), + "name": f"{staff.first_name} {staff.last_name}", + "email": staff.email, + "hospital": staff.hospital.name if staff.hospital else None, + "department": staff.department.name if staff.department else None, + "role": staff.get_role_names()[0] if staff.get_role_names() else "Staff", }, - 'performance_score': performance_score, - 'period': { - 'start': start_date.isoformat(), - 'end': end_date.isoformat(), - 'days': (end_date - start_date).days + "performance_score": performance_score, + "period": { + "start": start_date.isoformat(), + "end": end_date.isoformat(), + "days": (end_date - start_date).days, }, - 'summary': { - 'total_complaints': complaint_metrics['total'], - 'total_inquiries': inquiry_metrics['total'], - 'complaint_resolution_rate': round( - (complaint_metrics['status']['resolved'] + complaint_metrics['status']['closed']) / - max(complaint_metrics['total'], 1) * 100, 1 + "summary": { + "total_complaints": complaint_metrics["total"], + "total_inquiries": inquiry_metrics["total"], + "complaint_resolution_rate": round( + (complaint_metrics["status"]["resolved"] + complaint_metrics["status"]["closed"]) + / max(complaint_metrics["total"], 1) + * 100, + 1, + ), + "inquiry_resolution_rate": round( + (inquiry_metrics["status"]["resolved"] + inquiry_metrics["status"]["closed"]) + / max(inquiry_metrics["total"], 1) + * 100, + 1, ), - 'inquiry_resolution_rate': round( - (inquiry_metrics['status']['resolved'] + inquiry_metrics['status']['closed']) / - max(inquiry_metrics['total'], 1) * 100, 1 - ) }, - 'complaint_metrics': complaint_metrics, - 'inquiry_metrics': inquiry_metrics, - 'daily_trends': daily_stats, - 'recent_complaints': [ + "complaint_metrics": complaint_metrics, + "inquiry_metrics": inquiry_metrics, + "daily_trends": daily_stats, + "recent_complaints": [ { - 'id': str(c.id), - 'title': c.title, - 'status': c.status, - 'severity': c.severity, - 'created_at': c.created_at.isoformat(), - 'patient': c.patient.get_full_name() if c.patient else None + "id": str(c.id), + "title": c.title, + "status": c.status, + "severity": c.severity, + "created_at": c.created_at.isoformat(), + "patient": c.patient.get_full_name() if c.patient else None, } for c in recent_complaints ], - 'recent_inquiries': [ + "recent_inquiries": [ { - 'id': str(i.id), - 'subject': i.subject, - 'status': i.status, - 'created_at': i.created_at.isoformat(), - 'patient': i.patient.get_full_name() if i.patient else None + "id": str(i.id), + "subject": i.subject, + "status": i.status, + "created_at": i.created_at.isoformat(), + "patient": i.patient.get_full_name() if i.patient else None, } for i in recent_inquiries - ] + ], } - + @staticmethod def _calculate_performance_score(complaint_metrics: Dict, inquiry_metrics: Dict) -> Dict[str, Any]: """ Calculate an overall performance score (0-100) based on multiple factors. - + Returns score breakdown and overall rating. """ scores = { - 'complaint_resolution': 0, - 'complaint_response_time': 0, - 'complaint_activation_time': 0, - 'inquiry_resolution': 0, - 'inquiry_response_time': 0, - 'workload': 0 + "complaint_resolution": 0, + "complaint_response_time": 0, + "complaint_activation_time": 0, + "inquiry_resolution": 0, + "inquiry_response_time": 0, + "workload": 0, } - - total_complaints = complaint_metrics['total'] - total_inquiries = inquiry_metrics['total'] - + + total_complaints = complaint_metrics["total"] + total_inquiries = inquiry_metrics["total"] + if total_complaints > 0: # Resolution score (40% weight) - resolved = complaint_metrics['status']['resolved'] + complaint_metrics['status']['closed'] - scores['complaint_resolution'] = min(100, (resolved / total_complaints) * 100) - + resolved = complaint_metrics["status"]["resolved"] + complaint_metrics["status"]["closed"] + scores["complaint_resolution"] = min(100, (resolved / total_complaints) * 100) + # Response time score (20% weight) - response = complaint_metrics['response_time'] - on_time = response['within_24h'] + response['within_48h'] - total_with_response = on_time + response['within_72h'] + response['more_than_72h'] + response = complaint_metrics["response_time"] + on_time = response["within_24h"] + response["within_48h"] + total_with_response = on_time + response["within_72h"] + response["more_than_72h"] if total_with_response > 0: - scores['complaint_response_time'] = min(100, (on_time / total_with_response) * 100) - + scores["complaint_response_time"] = min(100, (on_time / total_with_response) * 100) + # Activation time score (10% weight) - activation = complaint_metrics['activation_time'] - if activation['within_2h'] + activation['more_than_2h'] > 0: - scores['complaint_activation_time'] = min(100, - (activation['within_2h'] / (activation['within_2h'] + activation['more_than_2h'])) * 100 + activation = complaint_metrics["activation_time"] + if activation["within_2h"] + activation["more_than_2h"] > 0: + scores["complaint_activation_time"] = min( + 100, (activation["within_2h"] / (activation["within_2h"] + activation["more_than_2h"])) * 100 ) - + if total_inquiries > 0: # Resolution score (15% weight) - resolved = inquiry_metrics['status']['resolved'] + inquiry_metrics['status']['closed'] - scores['inquiry_resolution'] = min(100, (resolved / total_inquiries) * 100) - + resolved = inquiry_metrics["status"]["resolved"] + inquiry_metrics["status"]["closed"] + scores["inquiry_resolution"] = min(100, (resolved / total_inquiries) * 100) + # Response time score (10% weight) - response = inquiry_metrics['response_time'] - on_time = response['within_24h'] + response['within_48h'] - total_with_response = on_time + response['within_72h'] + response['more_than_72h'] + response = inquiry_metrics["response_time"] + on_time = response["within_24h"] + response["within_48h"] + total_with_response = on_time + response["within_72h"] + response["more_than_72h"] if total_with_response > 0: - scores['inquiry_response_time'] = min(100, (on_time / total_with_response) * 100) - + scores["inquiry_response_time"] = min(100, (on_time / total_with_response) * 100) + # Workload score based on having reasonable volume (5% weight) total_items = total_complaints + total_inquiries if total_items >= 5: - scores['workload'] = 100 + scores["workload"] = 100 elif total_items > 0: - scores['workload'] = (total_items / 5) * 100 - + scores["workload"] = (total_items / 5) * 100 + # Calculate weighted overall score weights = { - 'complaint_resolution': 0.25, - 'complaint_response_time': 0.15, - 'complaint_activation_time': 0.10, - 'inquiry_resolution': 0.20, - 'inquiry_response_time': 0.15, - 'workload': 0.15 + "complaint_resolution": 0.25, + "complaint_response_time": 0.15, + "complaint_activation_time": 0.10, + "inquiry_resolution": 0.20, + "inquiry_response_time": 0.15, + "workload": 0.15, } - + overall_score = sum(scores[k] * weights[k] for k in scores) - + # Determine rating if overall_score >= 90: - rating = 'Excellent' - rating_color = 'success' + rating = "Excellent" + rating_color = "success" elif overall_score >= 75: - rating = 'Good' - rating_color = 'info' + rating = "Good" + rating_color = "info" elif overall_score >= 60: - rating = 'Average' - rating_color = 'warning' + rating = "Average" + rating_color = "warning" elif overall_score >= 40: - rating = 'Below Average' - rating_color = 'danger' + rating = "Below Average" + rating_color = "danger" else: - rating = 'Needs Improvement' - rating_color = 'dark' - + rating = "Needs Improvement" + rating_color = "dark" + return { - 'overall': round(overall_score, 1), - 'breakdown': scores, - 'rating': rating, - 'rating_color': rating_color, - 'total_items_handled': total_complaints + total_inquiries + "overall": round(overall_score, 1), + "breakdown": scores, + "rating": rating, + "rating_color": rating_color, + "total_items_handled": total_complaints + total_inquiries, } - + @staticmethod - def get_staff_performance_trends( - staff_id: str, - user, - months: int = 6 - ) -> List[Dict[str, Any]]: + def get_staff_performance_trends(staff_id: str, user, months: int = 6) -> List[Dict[str, Any]]: """ Get monthly performance trends for a staff member. - + Args: staff_id: Staff member UUID user: Current user months: Number of months to look back - + Returns: list: Monthly performance data """ from apps.accounts.models import User - + staff = User.objects.get(id=staff_id) - + # Check permissions if not user.is_px_admin(): if user.hospital and staff.hospital != user.hospital: raise PermissionError("Cannot view staff from other hospitals") - + trends = [] now = timezone.now() - + for i in range(months - 1, -1, -1): # Calculate month month_date = now - timedelta(days=i * 30) @@ -1109,154 +1057,139 @@ class UnifiedAnalyticsService: month_end = month_date.replace(year=month_date.year + 1, month=1, day=1) - timedelta(seconds=1) else: month_end = month_date.replace(month=month_date.month + 1, day=1) - timedelta(seconds=1) - + # Get complaints for this month complaints = Complaint.objects.filter( - assigned_to=staff, - created_at__gte=month_start, - created_at__lte=month_end + assigned_to=staff, created_at__gte=month_start, created_at__lte=month_end ) - + # Get inquiries for this month inquiries = Inquiry.objects.filter( - assigned_to=staff, - created_at__gte=month_start, - created_at__lte=month_end + assigned_to=staff, created_at__gte=month_start, created_at__lte=month_end ) - + complaint_metrics = UnifiedAnalyticsService._calculate_complaint_metrics(complaints) inquiry_metrics = UnifiedAnalyticsService._calculate_inquiry_metrics(inquiries) - - score_data = UnifiedAnalyticsService._calculate_performance_score( - complaint_metrics, inquiry_metrics + + score_data = UnifiedAnalyticsService._calculate_performance_score(complaint_metrics, inquiry_metrics) + + trends.append( + { + "month": month_start.strftime("%Y-%m"), + "month_name": month_start.strftime("%b %Y"), + "performance_score": score_data["overall"], + "rating": score_data["rating"], + "complaints_total": complaint_metrics["total"], + "complaints_resolved": complaint_metrics["status"]["resolved"] + + complaint_metrics["status"]["closed"], + "inquiries_total": inquiry_metrics["total"], + "inquiries_resolved": inquiry_metrics["status"]["resolved"] + inquiry_metrics["status"]["closed"], + } ) - - trends.append({ - 'month': month_start.strftime('%Y-%m'), - 'month_name': month_start.strftime('%b %Y'), - 'performance_score': score_data['overall'], - 'rating': score_data['rating'], - 'complaints_total': complaint_metrics['total'], - 'complaints_resolved': complaint_metrics['status']['resolved'] + complaint_metrics['status']['closed'], - 'inquiries_total': inquiry_metrics['total'], - 'inquiries_resolved': inquiry_metrics['status']['resolved'] + inquiry_metrics['status']['closed'] - }) - + return trends - + @staticmethod def get_department_benchmarks( user, department_id: Optional[str] = None, - date_range: str = '30d', + date_range: str = "30d", custom_start: Optional[datetime] = None, - custom_end: Optional[datetime] = None + custom_end: Optional[datetime] = None, ) -> Dict[str, Any]: """ Get benchmarking data comparing staff within a department. - + Args: user: Current user department_id: Optional department filter date_range: Date range filter custom_start: Custom start date custom_end: Custom end date - + Returns: dict: Benchmarking metrics """ from apps.accounts.models import User from apps.organizations.models import Department - - start_date, end_date = UnifiedAnalyticsService._get_date_range( - date_range, custom_start, custom_end - ) - + + start_date, end_date = UnifiedAnalyticsService._get_date_range(date_range, custom_start, custom_end) + # Get department if department_id: department = Department.objects.get(id=department_id) elif user.department: department = user.department else: - return {'error': 'No department specified'} - + return {"error": "No department specified"} + # Get all staff in department - staff_qs = User.objects.filter( - department=department, - is_active=True - ).filter( - Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False) - ).distinct() - + staff_qs = ( + User.objects.filter(department=department, is_active=True) + .filter(Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False)) + .distinct() + ) + staff_scores = [] - + for staff in staff_qs: complaints = Complaint.objects.filter( - assigned_to=staff, - created_at__gte=start_date, - created_at__lte=end_date + assigned_to=staff, created_at__gte=start_date, created_at__lte=end_date ) - - inquiries = Inquiry.objects.filter( - assigned_to=staff, - created_at__gte=start_date, - created_at__lte=end_date - ) - + + inquiries = Inquiry.objects.filter(assigned_to=staff, created_at__gte=start_date, created_at__lte=end_date) + complaint_metrics = UnifiedAnalyticsService._calculate_complaint_metrics(complaints) inquiry_metrics = UnifiedAnalyticsService._calculate_inquiry_metrics(inquiries) - - score_data = UnifiedAnalyticsService._calculate_performance_score( - complaint_metrics, inquiry_metrics + + score_data = UnifiedAnalyticsService._calculate_performance_score(complaint_metrics, inquiry_metrics) + + staff_scores.append( + { + "id": str(staff.id), + "name": f"{staff.first_name} {staff.last_name}", + "score": score_data["overall"], + "rating": score_data["rating"], + "total_items": score_data["total_items_handled"], + "complaints": complaint_metrics["total"], + "inquiries": inquiry_metrics["total"], + } ) - - staff_scores.append({ - 'id': str(staff.id), - 'name': f"{staff.first_name} {staff.last_name}", - 'score': score_data['overall'], - 'rating': score_data['rating'], - 'total_items': score_data['total_items_handled'], - 'complaints': complaint_metrics['total'], - 'inquiries': inquiry_metrics['total'] - }) - + # Sort by score - staff_scores.sort(key=lambda x: x['score'], reverse=True) - + staff_scores.sort(key=lambda x: x["score"], reverse=True) + # Calculate averages if staff_scores: - avg_score = sum(s['score'] for s in staff_scores) / len(staff_scores) - avg_items = sum(s['total_items'] for s in staff_scores) / len(staff_scores) + avg_score = sum(s["score"] for s in staff_scores) / len(staff_scores) + avg_items = sum(s["total_items"] for s in staff_scores) / len(staff_scores) else: avg_score = 0 avg_items = 0 - + return { - 'department': department.name, - 'period': { - 'start': start_date.isoformat(), - 'end': end_date.isoformat() - }, - 'staff_count': len(staff_scores), - 'average_score': round(avg_score, 1), - 'average_items_per_staff': round(avg_items, 1), - 'top_performer': staff_scores[0] if staff_scores else None, - 'needs_improvement': [s for s in staff_scores if s['score'] < 60], - 'rankings': staff_scores + "department": department.name, + "period": {"start": start_date.isoformat(), "end": end_date.isoformat()}, + "staff_count": len(staff_scores), + "average_score": round(avg_score, 1), + "average_items_per_staff": round(avg_items, 1), + "top_performer": staff_scores[0] if staff_scores else None, + "needs_improvement": [s for s in staff_scores if s["score"] < 60], + "rankings": staff_scores, } - + @staticmethod def export_staff_performance_report( staff_ids: List[str], user, - date_range: str = '30d', + date_range: str = "30d", custom_start: Optional[datetime] = None, custom_end: Optional[datetime] = None, - format_type: str = 'csv' + format_type: str = "csv", ) -> Dict[str, Any]: """ Generate exportable staff performance report. - + Args: staff_ids: List of staff UUIDs to include user: Current user @@ -1264,71 +1197,705 @@ class UnifiedAnalyticsService: custom_start: Custom start date custom_end: Custom end date format_type: Export format ('csv', 'excel', 'json') - + Returns: dict: Report data and metadata """ - start_date, end_date = UnifiedAnalyticsService._get_date_range( - date_range, custom_start, custom_end - ) - + start_date, end_date = UnifiedAnalyticsService._get_date_range(date_range, custom_start, custom_end) + # Get performance data performance_data = UnifiedAnalyticsService.get_staff_performance_metrics( user=user, date_range=date_range, staff_ids=staff_ids if staff_ids else None, custom_start=custom_start, - custom_end=custom_end + custom_end=custom_end, ) - + # Format for export export_rows = [] - - for staff in performance_data['staff_metrics']: - c = staff['complaints'] - i = staff['inquiries'] - + + for staff in performance_data["staff_metrics"]: + c = staff["complaints"] + i = staff["inquiries"] + # Calculate additional metrics complaint_resolution_rate = 0 - if c['total'] > 0: + if c["total"] > 0: complaint_resolution_rate = round( - (c['status']['resolved'] + c['status']['closed']) / c['total'] * 100, 1 + (c["status"]["resolved"] + c["status"]["closed"]) / c["total"] * 100, 1 ) - + inquiry_resolution_rate = 0 - if i['total'] > 0: - inquiry_resolution_rate = round( - (i['status']['resolved'] + i['status']['closed']) / i['total'] * 100, 1 - ) - - export_rows.append({ - 'staff_name': staff['name'], - 'email': staff['email'], - 'hospital': staff['hospital'], - 'department': staff['department'], - 'complaints_total': c['total'], - 'complaints_internal': c['internal'], - 'complaints_external': c['external'], - 'complaints_open': c['status']['open'], - 'complaints_resolved': c['status']['resolved'], - 'complaints_closed': c['status']['closed'], - 'complaint_resolution_rate': f"{complaint_resolution_rate}%", - 'complaint_activation_within_2h': c['activation_time']['within_2h'], - 'complaint_response_within_24h': c['response_time']['within_24h'], - 'inquiries_total': i['total'], - 'inquiries_open': i['status']['open'], - 'inquiries_resolved': i['status']['resolved'], - 'inquiry_resolution_rate': f"{inquiry_resolution_rate}%", - 'inquiry_response_within_24h': i['response_time']['within_24h'] - }) - + if i["total"] > 0: + inquiry_resolution_rate = round((i["status"]["resolved"] + i["status"]["closed"]) / i["total"] * 100, 1) + + export_rows.append( + { + "staff_name": staff["name"], + "email": staff["email"], + "hospital": staff["hospital"], + "department": staff["department"], + "complaints_total": c["total"], + "complaints_internal": c["internal"], + "complaints_external": c["external"], + "complaints_open": c["status"]["open"], + "complaints_resolved": c["status"]["resolved"], + "complaints_closed": c["status"]["closed"], + "complaint_resolution_rate": f"{complaint_resolution_rate}%", + "complaint_activation_within_2h": c["activation_time"]["within_2h"], + "complaint_response_within_24h": c["response_time"]["within_24h"], + "inquiries_total": i["total"], + "inquiries_open": i["status"]["open"], + "inquiries_resolved": i["status"]["resolved"], + "inquiry_resolution_rate": f"{inquiry_resolution_rate}%", + "inquiry_response_within_24h": i["response_time"]["within_24h"], + } + ) + return { - 'format': format_type, - 'generated_at': timezone.now().isoformat(), - 'period': { - 'start': start_date.isoformat(), - 'end': end_date.isoformat() - }, - 'total_staff': len(export_rows), - 'data': export_rows + "format": format_type, + "generated_at": timezone.now().isoformat(), + "period": {"start": start_date.isoformat(), "end": end_date.isoformat()}, + "total_staff": len(export_rows), + "data": export_rows, } + + # ============================================================================ + # EMPLOYEE EVALUATION DASHBOARD METHODS + # ============================================================================ + + @staticmethod + def get_employee_evaluation_metrics( + user, + date_range: str = "7d", + hospital_id: Optional[str] = None, + department_id: Optional[str] = None, + staff_ids: Optional[List[str]] = None, + custom_start: Optional[datetime] = None, + custom_end: Optional[datetime] = None, + ) -> Dict[str, Any]: + """ + Get comprehensive employee evaluation metrics for the PAD Department Weekly Dashboard. + + Returns metrics for all 11 sections of the evaluation dashboard: + 1. Complaints by Response Time + 2. Complaint Source Breakdown + 3. Response Time by Source (CHI vs MOH) + 4. Patient Type Breakdown + 5. Department Type Breakdown + 6. Delays and Activation + 7. Escalated Complaints + 8. Inquiries + 9. Notes + 10. Complaint Request & Filling Details + 11. Report Completion Tracker + + Args: + user: Current user + date_range: Date range filter + hospital_id: Optional hospital filter + department_id: Optional department filter + staff_ids: Optional list of specific staff IDs to evaluate + custom_start: Custom start date + custom_end: Custom end date + + Returns: + dict: Employee evaluation metrics with all 11 sections per staff member + """ + from apps.accounts.models import User + from apps.dashboard.models import ( + EvaluationNote, + ComplaintRequest, + ReportCompletion, + EscalatedComplaintLog, + InquiryDetail, + ) + + start_date, end_date = UnifiedAnalyticsService._get_date_range(date_range, custom_start, custom_end) + + # Get staff queryset + staff_qs = User.objects.all() + + # Filter by role + if not user.is_px_admin() and user.hospital: + staff_qs = staff_qs.filter(hospital=user.hospital) + + # Apply filters + if hospital_id: + staff_qs = staff_qs.filter(hospital_id=hospital_id) + + if department_id: + staff_qs = staff_qs.filter(department_id=department_id) + + if staff_ids: + staff_qs = staff_qs.filter(id__in=staff_ids) + + # Only staff with assigned complaints or inquiries + staff_qs = ( + staff_qs.filter(Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False)) + .distinct() + .prefetch_related("assigned_complaints", "assigned_inquiries") + ) + + staff_metrics = [] + + for staff_member in staff_qs: + staff_data = { + "id": str(staff_member.id), + "name": f"{staff_member.first_name} {staff_member.last_name}", + "email": staff_member.email, + "hospital": staff_member.hospital.name if staff_member.hospital else None, + "department": staff_member.department.name if staff_member.department else None, + } + + # Get all metrics for this staff member + staff_data["complaints_response_time"] = UnifiedAnalyticsService._get_complaint_response_time_breakdown( + staff_member.id, start_date, end_date + ) + staff_data["complaint_sources"] = UnifiedAnalyticsService._get_complaint_source_breakdown( + staff_member.id, start_date, end_date + ) + staff_data["response_time_by_source"] = UnifiedAnalyticsService._get_response_time_by_source( + staff_member.id, start_date, end_date + ) + staff_data["patient_type_breakdown"] = UnifiedAnalyticsService._get_patient_type_breakdown( + staff_member.id, start_date, end_date + ) + staff_data["department_type_breakdown"] = UnifiedAnalyticsService._get_department_type_breakdown( + staff_member.id, start_date, end_date + ) + staff_data["delays_activation"] = UnifiedAnalyticsService._get_delays_and_activation( + staff_member.id, start_date, end_date + ) + staff_data["escalated_complaints"] = UnifiedAnalyticsService._get_escalated_complaints_breakdown( + staff_member.id, start_date, end_date + ) + staff_data["inquiries"] = UnifiedAnalyticsService._get_inquiries_breakdown( + staff_member.id, start_date, end_date + ) + staff_data["notes"] = UnifiedAnalyticsService._get_notes_breakdown(staff_member.id, start_date, end_date) + staff_data["complaint_requests"] = UnifiedAnalyticsService._get_complaint_request_details( + staff_member.id, start_date, end_date + ) + staff_data["report_completion"] = UnifiedAnalyticsService._get_report_completion_tracker( + staff_member.id, start_date + ) + + staff_metrics.append(staff_data) + + # Calculate summary totals across all staff + summary = UnifiedAnalyticsService._get_evaluation_summary_totals(staff_metrics) + + return { + "staff_metrics": staff_metrics, + "summary": summary, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "date_range": date_range, + } + + @staticmethod + def _get_complaint_response_time_breakdown(staff_id, start_date, end_date): + """ + Get complaint response time breakdown for a staff member. + + Returns counts and percentages for: 24h, 48h, 72h, >72h + """ + from apps.complaints.models import Complaint + + complaints = Complaint.objects.filter( + assigned_to_id=staff_id, created_at__gte=start_date, created_at__lte=end_date + ) + + total = complaints.count() + + if total == 0: + return { + "24h": 0, + "48h": 0, + "72h": 0, + "more_than_72h": 0, + "total": 0, + "percentages": {"24h": 0, "48h": 0, "72h": 0, "more_than_72h": 0}, + } + + # Count by response time + count_24h = 0 + count_48h = 0 + count_72h = 0 + count_more_72h = 0 + + for complaint in complaints: + first_update = complaint.updates.first() + if first_update: + response_time_hours = (first_update.created_at - complaint.created_at).total_seconds() / 3600 + if response_time_hours <= 24: + count_24h += 1 + elif response_time_hours <= 48: + count_48h += 1 + elif response_time_hours <= 72: + count_72h += 1 + else: + count_more_72h += 1 + + return { + "24h": count_24h, + "48h": count_48h, + "72h": count_72h, + "more_than_72h": count_more_72h, + "total": total, + "percentages": { + "24h": round((count_24h / total) * 100, 1), + "48h": round((count_48h / total) * 100, 1), + "72h": round((count_72h / total) * 100, 1), + "more_than_72h": round((count_more_72h / total) * 100, 1), + }, + } + + @staticmethod + def _get_complaint_source_breakdown(staff_id, start_date, end_date): + """ + Get complaint source breakdown for a staff member. + + Returns counts for: MOH, CCHI, Patients, Patient's relatives, Insurance company + """ + from apps.complaints.models import Complaint + + complaints = Complaint.objects.filter( + assigned_to_id=staff_id, created_at__gte=start_date, created_at__lte=end_date + ).select_related("source") + + # Source mapping based on PXSource names + source_counts = {"MOH": 0, "CCHI": 0, "Patients": 0, "Patient_relatives": 0, "Insurance_company": 0} + + for complaint in complaints: + if complaint.source: + source_name = complaint.source.name_en.upper() + if "MOH" in source_name or "MINISTRY" in source_name: + source_counts["MOH"] += 1 + elif "CCHI" in source_name or "CHI" in source_name or "COUNCIL" in source_name: + source_counts["CCHI"] += 1 + elif "PATIENT" in source_name: + source_counts["Patients"] += 1 + elif "FAMILY" in source_name or "RELATIVE" in source_name: + source_counts["Patient_relatives"] += 1 + elif "INSURANCE" in source_name: + source_counts["Insurance_company"] += 1 + + # Calculate source total and percentages + source_total = sum(source_counts.values()) + + if source_total > 0: + percentages = {k: round((v / source_total) * 100, 1) for k, v in source_counts.items()} + else: + percentages = {k: 0 for k in source_counts} + + return {"counts": source_counts, "total": source_total, "percentages": percentages} + + @staticmethod + def _get_response_time_by_source(staff_id, start_date, end_date): + """ + Get response time breakdown by source (CHI vs MOH). + + Returns matrix of CHI and MOH counts by time category. + """ + from apps.complaints.models import Complaint + + complaints = Complaint.objects.filter( + assigned_to_id=staff_id, created_at__gte=start_date, created_at__lte=end_date + ).select_related("source") + + # Initialize counts + time_categories = ["24h", "48h", "72h", "more_than_72h"] + chi_counts = {cat: 0 for cat in time_categories} + moh_counts = {cat: 0 for cat in time_categories} + + for complaint in complaints: + if not complaint.source: + continue + + source_name = complaint.source.name_en.upper() + is_chi = "CCHI" in source_name or "CHI" in source_name + is_moh = "MOH" in source_name or "MINISTRY" in source_name + + if not (is_chi or is_moh): + continue + + # Determine response time category + first_update = complaint.updates.first() + if not first_update: + continue + + response_time_hours = (first_update.created_at - complaint.created_at).total_seconds() / 3600 + + if response_time_hours <= 24: + time_cat = "24h" + elif response_time_hours <= 48: + time_cat = "48h" + elif response_time_hours <= 72: + time_cat = "72h" + else: + time_cat = "more_than_72h" + + if is_chi: + chi_counts[time_cat] += 1 + elif is_moh: + moh_counts[time_cat] += 1 + + return { + "24h": {"CHI": chi_counts["24h"], "MOH": moh_counts["24h"]}, + "48h": {"CHI": chi_counts["48h"], "MOH": moh_counts["48h"]}, + "72h": {"CHI": chi_counts["72h"], "MOH": moh_counts["72h"]}, + "more_than_72h": {"CHI": chi_counts["more_than_72h"], "MOH": moh_counts["more_than_72h"]}, + "totals": {"CHI": sum(chi_counts.values()), "MOH": sum(moh_counts.values())}, + } + + @staticmethod + def _get_patient_type_breakdown(staff_id, start_date, end_date): + """ + Get patient type breakdown for a staff member. + + Returns counts for: In-Patient, Out-Patient, ER + """ + from apps.complaints.models import Complaint + + complaints = Complaint.objects.filter( + assigned_to_id=staff_id, created_at__gte=start_date, created_at__lte=end_date + ) + + # For this implementation, we'll use encounter_id presence as a proxy + # In a real implementation, you'd check the patient's admission status + # For now, using a simplified approach based on metadata or location + + counts = {"In_Patient": 0, "Out_Patient": 0, "ER": 0} + + for complaint in complaints: + # Check metadata for patient type if available + if complaint.metadata and "patient_type" in complaint.metadata: + pt = complaint.metadata["patient_type"] + if pt in counts: + counts[pt] += 1 + else: + # Default to Out-Patient if no info available + # In a real implementation, check against the Patient model + counts["Out_Patient"] += 1 + + total = sum(counts.values()) + + if total > 0: + percentages = {k: round((v / total) * 100, 1) for k, v in counts.items()} + else: + percentages = {k: 0 for k in counts} + + return {"counts": counts, "total": total, "percentages": percentages} + + @staticmethod + def _get_department_type_breakdown(staff_id, start_date, end_date): + """ + Get department type breakdown for a staff member. + + Returns counts for: Medical, Admin, Nursing, Support Services + """ + from apps.complaints.models import Complaint + + complaints = Complaint.objects.filter( + assigned_to_id=staff_id, created_at__gte=start_date, created_at__lte=end_date + ).select_related("department") + + counts = {"Medical": 0, "Admin": 0, "Nursing": 0, "Support_Services": 0} + + for complaint in complaints: + if complaint.department: + dept_name = complaint.department.name.upper() + if "MEDICAL" in dept_name or "CLINIC" in dept_name: + counts["Medical"] += 1 + elif "ADMIN" in dept_name or "HR" in dept_name or "FINANCE" in dept_name: + counts["Admin"] += 1 + elif "NURS" in dept_name: + counts["Nursing"] += 1 + elif "SUPPORT" in dept_name or "MAINT" in dept_name or "HOUSEKEEP" in dept_name: + counts["Support_Services"] += 1 + else: + # Default to Medical if uncertain + counts["Medical"] += 1 + else: + counts["Medical"] += 1 + + total = sum(counts.values()) + + if total > 0: + percentages = {k: round((v / total) * 100, 1) for k, v in counts.items()} + else: + percentages = {k: 0 for k in counts} + + return {"counts": counts, "total": total, "percentages": percentages} + + @staticmethod + def _get_delays_and_activation(staff_id, start_date, end_date): + """ + Get delays in activation and activation within 2 hours. + + Returns delays count, activation count, and percentages. + """ + from apps.complaints.models import Complaint + + complaints = Complaint.objects.filter( + assigned_to_id=staff_id, created_at__gte=start_date, created_at__lte=end_date + ) + + total = complaints.count() + + delays = 0 + activated_within_2h = 0 + + for complaint in complaints: + if complaint.activated_at: + activation_time_hours = (complaint.activated_at - complaint.created_at).total_seconds() / 3600 + if activation_time_hours <= 2: + activated_within_2h += 1 + else: + delays += 1 + else: + # Not activated = delay + delays += 1 + + return { + "delays": delays, + "activated_within_2h": activated_within_2h, + "total": total, + "percentages": { + "delays": round((delays / total) * 100, 1) if total > 0 else 0, + "activated": round((activated_within_2h / total) * 100, 1) if total > 0 else 0, + }, + } + + @staticmethod + def _get_escalated_complaints_breakdown(staff_id, start_date, end_date): + """ + Get escalated complaints breakdown. + + Returns counts for: Before 72h, Exactly 72h, After 72h, Resolved + """ + from apps.dashboard.models import EscalatedComplaintLog + + logs = EscalatedComplaintLog.objects.filter( + staff_id=staff_id, week_start_date__gte=start_date.date(), week_start_date__lte=end_date.date() + ) + + counts = {"before_72h": 0, "exactly_72h": 0, "after_72h": 0, "resolved": 0} + + for log in logs: + if log.escalation_timing == "before_72h": + counts["before_72h"] += 1 + elif log.escalation_timing == "exactly_72h": + counts["exactly_72h"] += 1 + elif log.escalation_timing == "after_72h": + counts["after_72h"] += 1 + + if log.is_resolved: + counts["resolved"] += 1 + + counts["total_escalated"] = counts["before_72h"] + counts["exactly_72h"] + counts["after_72h"] + + return counts + + @staticmethod + def _get_inquiries_breakdown(staff_id, start_date, end_date): + """ + Get inquiries breakdown with incoming/outgoing details. + + Returns detailed breakdown for incoming and outgoing inquiries. + """ + from apps.dashboard.models import InquiryDetail + + inquiry_details = InquiryDetail.objects.filter( + staff_id=staff_id, inquiry_date__gte=start_date.date(), inquiry_date__lte=end_date.date() + ) + + incoming = inquiry_details.filter(is_outgoing=False) + outgoing = inquiry_details.filter(is_outgoing=True) + + def get_inquiry_metrics(queryset): + """Helper to calculate metrics for a queryset""" + time_counts = {"24h": 0, "48h": 0, "72h": 0, "more_than_72h": 0} + status_counts = {"in_progress": 0, "contacted": 0, "contacted_no_response": 0} + + for detail in queryset: + if detail.response_time_category: + time_counts[detail.response_time_category] += 1 + if detail.inquiry_status: + status_counts[detail.inquiry_status] += 1 + + return {"total": queryset.count(), "by_time": time_counts, "by_status": status_counts} + + # Get inquiry type breakdowns + inquiry_types = dict(InquiryDetail.INQUIRY_TYPE_CHOICES) + incoming_types = {t[0]: incoming.filter(inquiry_type=t[0]).count() for t in InquiryDetail.INQUIRY_TYPE_CHOICES} + outgoing_types = {t[0]: outgoing.filter(inquiry_type=t[0]).count() for t in InquiryDetail.INQUIRY_TYPE_CHOICES} + + return { + "incoming": {**get_inquiry_metrics(incoming), "by_type": incoming_types}, + "outgoing": {**get_inquiry_metrics(outgoing), "by_type": outgoing_types}, + "total": inquiry_details.count(), + } + + @staticmethod + def _get_notes_breakdown(staff_id, start_date, end_date): + """ + Get notes breakdown by category and sub-category. + """ + from apps.dashboard.models import EvaluationNote + + notes = EvaluationNote.objects.filter( + staff_id=staff_id, note_date__gte=start_date.date(), note_date__lte=end_date.date() + ) + + total = notes.aggregate(total=Sum("count"))["total"] or 0 + + # Breakdown by category + by_category = {} + categories = dict(EvaluationNote.CATEGORY_CHOICES) + subcategories = dict(EvaluationNote.SUBCATEGORY_CHOICES) + + for note in notes: + cat_key = note.category + subcat_key = note.sub_category + + if cat_key not in by_category: + by_category[cat_key] = {"name": categories.get(cat_key, cat_key), "total": 0, "subcategories": {}} + + by_category[cat_key]["total"] += note.count + + if subcat_key not in by_category[cat_key]["subcategories"]: + by_category[cat_key]["subcategories"][subcat_key] = { + "name": subcategories.get(subcat_key, subcat_key), + "count": 0, + } + + by_category[cat_key]["subcategories"][subcat_key]["count"] += note.count + + return {"total": total, "by_category": by_category} + + @staticmethod + def _get_complaint_request_details(staff_id, start_date, end_date): + """ + Get complaint request filling details. + """ + from apps.dashboard.models import ComplaintRequest + + requests = ComplaintRequest.objects.filter( + staff_id=staff_id, request_date__gte=start_date.date(), request_date__lte=end_date.date() + ) + + total = requests.count() + filled = requests.filter(filled=True).count() + not_filled = requests.filter(not_filled=True).count() + on_hold = requests.filter(on_hold=True).count() + from_barcode = requests.filter(from_barcode=True).count() + + # Filling time breakdown + filling_times = dict(ComplaintRequest.FILLING_TIME_CHOICES) + time_breakdown = { + t[0]: requests.filter(filling_time_category=t[0]).count() for t in ComplaintRequest.FILLING_TIME_CHOICES + } + + return { + "total": total, + "filled": filled, + "not_filled": not_filled, + "on_hold": on_hold, + "from_barcode": from_barcode, + "filling_time_breakdown": time_breakdown, + "percentages": { + "filled": round((filled / total) * 100, 1) if total > 0 else 0, + "not_filled": round((not_filled / total) * 100, 1) if total > 0 else 0, + "on_hold": round((on_hold / total) * 100, 1) if total > 0 else 0, + }, + } + + @staticmethod + def _get_report_completion_tracker(staff_id, week_start_date): + """ + Get report completion status for a staff member. + """ + from apps.dashboard.models import ReportCompletion + + completions = ReportCompletion.objects.filter( + staff_id=staff_id, + week_start_date=week_start_date.date() if hasattr(week_start_date, "date") else week_start_date, + ) + + reports = [] + total_reports = len(ReportCompletion.REPORT_TYPE_CHOICES) + completed_count = 0 + + for report_type, report_name in ReportCompletion.REPORT_TYPE_CHOICES: + completion = completions.filter(report_type=report_type).first() + is_completed = completion.is_completed if completion else False + + if is_completed: + completed_count += 1 + + reports.append( + { + "type": report_type, + "name": report_name, + "completed": is_completed, + "completed_at": completion.completed_at.isoformat() + if completion and completion.completed_at + else None, + } + ) + + completion_percentage = round((completed_count / total_reports) * 100, 1) if total_reports > 0 else 0 + + return { + "reports": reports, + "completed_count": completed_count, + "total_reports": total_reports, + "completion_percentage": completion_percentage, + } + + @staticmethod + def _get_evaluation_summary_totals(staff_metrics): + """ + Calculate summary totals across all staff members. + """ + summary = { + "total_complaints": 0, + "total_inquiries": 0, + "total_notes": 0, + "total_escalated": 0, + "complaints_by_response_time": {"24h": 0, "48h": 0, "72h": 0, "more_than_72h": 0}, + "complaints_by_source": { + "MOH": 0, + "CCHI": 0, + "Patients": 0, + "Patient_relatives": 0, + "Insurance_company": 0, + }, + } + + for staff in staff_metrics: + # Total complaints + summary["total_complaints"] += staff["complaints_response_time"]["total"] + + # Total inquiries + summary["total_inquiries"] += staff["inquiries"]["total"] + + # Total notes + summary["total_notes"] += staff["notes"]["total"] + + # Total escalated + summary["total_escalated"] += staff["escalated_complaints"].get("total_escalated", 0) + + # Response time totals + for key in ["24h", "48h", "72h", "more_than_72h"]: + summary["complaints_by_response_time"][key] += staff["complaints_response_time"].get(key, 0) + + # Source totals + for key in summary["complaints_by_source"]: + summary["complaints_by_source"][key] += staff["complaint_sources"]["counts"].get(key, 0) + + return summary diff --git a/apps/analytics/tasks.py b/apps/analytics/tasks.py new file mode 100644 index 0000000..f0fa0bd --- /dev/null +++ b/apps/analytics/tasks.py @@ -0,0 +1,34 @@ +import logging + +from celery import shared_task +from django.utils import timezone + +from apps.organizations.models import Hospital + +from .kpi_models import KPIReportType +from .kpi_service import KPICalculationService + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, ignore_result=True) +def calculate_daily_kpis(self): + today = timezone.now() + year = today.year + month = today.month + + hospitals = Hospital.objects.filter(status="active") + report_types = [rt[0] for rt in KPIReportType.choices] + + for hospital in hospitals: + for report_type in report_types: + try: + KPICalculationService.generate_monthly_report( + report_type=report_type, + hospital=hospital, + year=year, + month=month, + ) + logger.info(f"Daily KPI calculated: {report_type} for {hospital.name} ({year}-{month:02d})") + except Exception as e: + logger.exception(f"Failed daily KPI: {report_type} for {hospital.name}: {e}") diff --git a/apps/complaints/admin.py b/apps/complaints/admin.py index ab65a3e..5342203 100644 --- a/apps/complaints/admin.py +++ b/apps/complaints/admin.py @@ -1,8 +1,9 @@ """ Complaints admin """ + from django.contrib import admin -from django.utils.html import format_html +from django.utils.html import format_html, mark_safe from .models import ( Complaint, @@ -24,119 +25,251 @@ from .models import ( ComplaintAdverseActionAttachment, ) -admin.site.register(ExplanationSLAConfig) + +class ExplanationSLAConfigAdmin(admin.ModelAdmin): + list_display = ( + "hospital", + "response_hours", + "reminder_hours_before", + "second_reminder_enabled", + "second_reminder_hours_before", + "auto_escalate_enabled", + "is_active", + ) + list_filter = ("is_active", "second_reminder_enabled", "auto_escalate_enabled") + fieldsets = ( + ( + None, + { + "fields": ( + "hospital", + "response_hours", + "reminder_hours_before", + "second_reminder_enabled", + "second_reminder_hours_before", + "is_active", + ) + }, + ), + ("Escalation", {"fields": ("auto_escalate_enabled", "escalation_hours_overdue", "max_escalation_levels")}), + ) + + +admin.site.register(ExplanationSLAConfig, ExplanationSLAConfigAdmin) + class ComplaintAttachmentInline(admin.TabularInline): """Inline admin for complaint attachments""" + model = ComplaintAttachment extra = 0 - fields = ['file', 'filename', 'file_size', 'uploaded_by', 'description'] - readonly_fields = ['file_size'] + fields = ["file", "filename", "file_size", "uploaded_by", "description"] + readonly_fields = ["file_size"] class ComplaintUpdateInline(admin.TabularInline): """Inline admin for complaint updates""" + model = ComplaintUpdate extra = 1 - fields = ['update_type', 'message', 'created_by', 'created_at'] - readonly_fields = ['created_at'] - ordering = ['-created_at'] + fields = ["update_type", "message", "created_by", "created_at"] + readonly_fields = ["created_at"] + ordering = ["-created_at"] class ComplaintInvolvedDepartmentInline(admin.TabularInline): """Inline admin for involved departments""" + model = ComplaintInvolvedDepartment extra = 0 - fields = ['department', 'role', 'is_primary', 'assigned_to', 'response_submitted'] - autocomplete_fields = ['department', 'assigned_to'] + fields = [ + "department", + "role", + "is_primary", + "assigned_to", + "forwarded_at", + "first_reminder_sent_at", + "second_reminder_sent_at", + "response_submitted", + "response_submitted_at", + "delay_reason", + "delayed_person", + ] + autocomplete_fields = ["department", "assigned_to"] + readonly_fields = ["response_submitted_at"] class ComplaintInvolvedStaffInline(admin.TabularInline): """Inline admin for involved staff""" + model = ComplaintInvolvedStaff extra = 0 - fields = ['staff', 'role', 'explanation_requested', 'explanation_received'] - autocomplete_fields = ['staff'] + fields = ["staff", "role", "explanation_requested", "explanation_received"] + autocomplete_fields = ["staff"] @admin.register(Complaint) class ComplaintAdmin(admin.ModelAdmin): """Complaint admin""" + list_display = [ - 'title_preview', 'complaint_type_badge', 'patient', 'hospital', - 'location_hierarchy', 'category', - 'severity_badge', 'status_badge', 'sla_indicator', - 'created_by', 'assigned_to', 'created_at' + "title_preview", + "complaint_type_badge", + "patient", + "hospital", + "location_hierarchy", + "category", + "severity_badge", + "status_badge", + "sla_indicator", + "satisfaction_badge", + "created_by", + "assigned_to", + "created_at", ] list_filter = [ - 'status', 'severity', 'priority', 'category', 'source', - 'location', 'main_section', 'subsection', - 'is_overdue', 'hospital', 'created_by', 'created_at' + "status", + "severity", + "priority", + "category", + "source", + "location", + "main_section", + "subsection", + "is_overdue", + "hospital", + "created_by", + "created_at", + "satisfaction", ] search_fields = [ - 'title', 'description', 'patient__mrn', - 'patient__first_name', 'patient__last_name', 'encounter_id' + "title", + "description", + "patient__mrn", + "patient__first_name", + "patient__last_name", + "encounter_id", + "file_number", + "moh_reference", + "chi_reference", + "complaint_subject", + ] + ordering = ["-created_at"] + date_hierarchy = "created_at" + inlines = [ + ComplaintUpdateInline, + ComplaintAttachmentInline, + ComplaintInvolvedDepartmentInline, + ComplaintInvolvedStaffInline, ] - ordering = ['-created_at'] - date_hierarchy = 'created_at' - inlines = [ComplaintUpdateInline, ComplaintAttachmentInline, ComplaintInvolvedDepartmentInline, ComplaintInvolvedStaffInline] fieldsets = ( - ('Patient & Encounter', { - 'fields': ('patient', 'encounter_id') - }), - ('Organization', { - 'fields': ('hospital', 'department', 'staff') - }), - ('Location Hierarchy', { - 'fields': ('location', 'main_section', 'subsection') - }), - ('Complaint Details', { - 'fields': ('complaint_type', 'title', 'description', 'category', 'subcategory') - }), - ('Classification', { - 'fields': ('priority', 'severity', 'source') - }), - ('Creator Tracking', { - 'fields': ('created_by',) - }), - ('Status & Assignment', { - 'fields': ('status', 'assigned_to', 'assigned_at', 'activated_at') - }), - ('SLA Tracking', { - 'fields': ('due_at', 'is_overdue', 'reminder_sent_at', 'escalated_at') - }), - ('Resolution', { - 'fields': ('resolution', 'resolved_at', 'resolved_by') - }), - ('Closure', { - 'fields': ('closed_at', 'closed_by', 'resolution_survey', 'resolution_survey_sent_at') - }), - ('Metadata', { - 'fields': ('metadata', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ( + "Patient & Encounter", + {"fields": ("patient", "patient_name", "file_number", "encounter_id", "incident_date", "contact_phone")}, + ), + ("Organization", {"fields": ("hospital", "department", "staff")}), + ("Location Hierarchy", {"fields": ("location", "main_section", "subsection")}), + ( + "Complaint Details", + { + "fields": ( + "complaint_type", + "title", + "ai_brief_en", + "ai_brief_ar", + "description", + "complaint_subject", + "category", + "subcategory", + ) + }, + ), + ("Classification", {"fields": ("priority", "severity", "source")}), + ( + "External References", + {"fields": ("moh_reference", "moh_reference_date", "chi_reference", "chi_reference_date")}, + ), + ("Creator Tracking", {"fields": ("created_by",)}), + ("Status & Assignment", {"fields": ("status", "assigned_to", "assigned_at", "activated_at")}), + ("Workflow Timeline", {"fields": ("form_sent_at", "forwarded_to_dept_at", "response_date")}), + ( + "SLA Tracking", + { + "fields": ( + "due_at", + "is_overdue", + "reminder_sent_at", + "second_reminder_sent_at", + "breached_at", + "escalated_at", + ) + }, + ), + ( + "Resolution", + { + "fields": ( + "resolution", + "resolution_category", + "resolution_outcome", + "resolution_outcome_other", + "resolved_at", + "resolved_by", + ) + }, + ), + ( + "Satisfaction", + { + "fields": ( + "satisfaction", + "action_taken_by_dept", + "action_result", + "recommendation_action_plan", + "delay_reason_closure", + "explanation_delay_reason", + ) + }, + ), + ("Closure", {"fields": ("closed_at", "closed_by", "resolution_survey", "resolution_survey_sent_at")}), + ("Metadata", {"fields": ("metadata", "created_at", "updated_at"), "classes": ("collapse",)}), ) readonly_fields = [ - 'assigned_at', 'activated_at', 'reminder_sent_at', 'escalated_at', - 'resolved_at', 'closed_at', 'resolution_survey_sent_at', - 'created_at', 'updated_at' + "assigned_at", + "activated_at", + "reminder_sent_at", + "escalated_at", + "resolved_at", + "closed_at", + "resolution_survey_sent_at", + "created_at", + "updated_at", ] def get_queryset(self, request): qs = super().get_queryset(request) return qs.select_related( - 'patient', 'hospital', 'department', 'staff', - 'location', 'main_section', 'subsection', - 'assigned_to', 'resolved_by', 'closed_by', 'resolution_survey', - 'created_by' + "patient", + "hospital", + "department", + "staff", + "location", + "main_section", + "subsection", + "assigned_to", + "resolved_by", + "closed_by", + "resolution_survey", + "created_by", ) def title_preview(self, obj): """Show preview of title""" - return obj.title[:60] + '...' if len(obj.title) > 60 else obj.title - title_preview.short_description = 'Title' + return obj.title[:60] + "..." if len(obj.title) > 60 else obj.title + + title_preview.short_description = "Title" def location_hierarchy(self, obj): """Display location hierarchy in admin""" @@ -147,240 +280,224 @@ class ComplaintAdmin(admin.ModelAdmin): parts.append(obj.main_section.name_en or obj.main_section.name_ar or str(obj.main_section)) if obj.subsection: parts.append(obj.subsection.name_en or obj.subsection.name_ar or str(obj.subsection)) - + if not parts: - return '—' - - hierarchy = ' → '.join(parts) + return "—" + + hierarchy = " → ".join(parts) return format_html('{0}', hierarchy) - location_hierarchy.short_description = 'Location' + + location_hierarchy.short_description = "Location" def severity_badge(self, obj): """Display severity with color badge""" colors = { - 'low': 'info', - 'medium': 'warning', - 'high': 'danger', - 'critical': 'danger', + "low": "info", + "medium": "warning", + "high": "danger", + "critical": "danger", } - color = colors.get(obj.severity, 'secondary') - return format_html( - '{1}', - color, - obj.get_severity_display() - ) - severity_badge.short_description = 'Severity' + color = colors.get(obj.severity, "secondary") + return format_html('{1}', color, obj.get_severity_display()) + + severity_badge.short_description = "Severity" def status_badge(self, obj): """Display status with color badge""" colors = { - 'open': 'danger', - 'in_progress': 'warning', - 'resolved': 'info', - 'closed': 'success', - 'cancelled': 'secondary', + "open": "warning", + "in_progress": "info", + "partially_resolved": "info", + "resolved": "success", + "closed": "secondary", + "cancelled": "secondary", + "contacted": "info", + "contacted_no_response": "danger", } - color = colors.get(obj.status, 'secondary') - return format_html( - '{1}', - color, - obj.get_status_display() - ) - status_badge.short_description = 'Status' + color = colors.get(obj.status, "secondary") + return format_html('{1}', color, obj.get_status_display()) + + status_badge.short_description = "Status" + + def satisfaction_badge(self, obj): + """Display satisfaction with color badge""" + if not obj.satisfaction: + return "—" + colors = { + "satisfied": "success", + "neutral": "warning", + "dissatisfied": "danger", + "no_response": "secondary", + "escalated": "danger", + } + color = colors.get(obj.satisfaction, "secondary") + return format_html('{1}', color, obj.get_satisfaction_display()) + + satisfaction_badge.short_description = "Satisfaction" def complaint_type_badge(self, obj): """Display complaint type with color badge""" colors = { - 'complaint': 'danger', - 'appreciation': 'success', + "complaint": "danger", + "appreciation": "success", } - color = colors.get(obj.complaint_type, 'secondary') - return format_html( - '{1}', - color, - obj.get_complaint_type_display() - ) - complaint_type_badge.short_description = 'Type' + color = colors.get(obj.complaint_type, "secondary") + return format_html('{1}', color, obj.get_complaint_type_display()) + + complaint_type_badge.short_description = "Type" def sla_indicator(self, obj): """Display SLA status""" if obj.is_overdue: - return format_html('OVERDUE') + return mark_safe('OVERDUE') from django.utils import timezone + time_remaining = obj.due_at - timezone.now() hours_remaining = time_remaining.total_seconds() / 3600 if hours_remaining < 4: - return format_html('DUE SOON') + return mark_safe('DUE SOON') else: - return format_html('ON TIME') - sla_indicator.short_description = 'SLA' + return mark_safe('ON TIME') + + sla_indicator.short_description = "SLA" @admin.register(ComplaintAttachment) class ComplaintAttachmentAdmin(admin.ModelAdmin): """Complaint attachment admin""" - list_display = ['complaint', 'filename', 'file_type', 'file_size', 'uploaded_by', 'created_at'] - list_filter = ['file_type', 'created_at'] - search_fields = ['filename', 'description', 'complaint__title'] - ordering = ['-created_at'] + + list_display = ["complaint", "filename", "file_type", "file_size", "uploaded_by", "created_at"] + list_filter = ["file_type", "created_at"] + search_fields = ["filename", "description", "complaint__title"] + ordering = ["-created_at"] fieldsets = ( - (None, { - 'fields': ('complaint', 'file', 'filename', 'file_type', 'file_size') - }), - ('Details', { - 'fields': ('uploaded_by', 'description') - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("complaint", "file", "filename", "file_type", "file_size")}), + ("Details", {"fields": ("uploaded_by", "description")}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - readonly_fields = ['file_size', 'created_at', 'updated_at'] + readonly_fields = ["file_size", "created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('complaint', 'uploaded_by') + return qs.select_related("complaint", "uploaded_by") @admin.register(ComplaintUpdate) class ComplaintUpdateAdmin(admin.ModelAdmin): """Complaint update admin""" - list_display = ['complaint', 'update_type', 'message_preview', 'created_by', 'created_at'] - list_filter = ['update_type', 'created_at'] - search_fields = ['message', 'complaint__title'] - ordering = ['-created_at'] + + list_display = ["complaint", "update_type", "message_preview", "created_by", "created_at"] + list_filter = ["update_type", "created_at"] + search_fields = ["message", "complaint__title"] + ordering = ["-created_at"] fieldsets = ( - (None, { - 'fields': ('complaint', 'update_type', 'message') - }), - ('Status Change', { - 'fields': ('old_status', 'new_status'), - 'classes': ('collapse',) - }), - ('Details', { - 'fields': ('created_by', 'metadata') - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("complaint", "update_type", "message")}), + ("Status Change", {"fields": ("old_status", "new_status"), "classes": ("collapse",)}), + ("Details", {"fields": ("created_by", "metadata")}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - readonly_fields = ['created_at', 'updated_at'] + readonly_fields = ["created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('complaint', 'created_by') + return qs.select_related("complaint", "created_by") def message_preview(self, obj): """Show preview of message""" - return obj.message[:100] + '...' if len(obj.message) > 100 else obj.message - message_preview.short_description = 'Message' + return obj.message[:100] + "..." if len(obj.message) > 100 else obj.message + + message_preview.short_description = "Message" @admin.register(Inquiry) class InquiryAdmin(admin.ModelAdmin): """Inquiry admin""" + list_display = [ - 'subject_preview', 'patient', 'contact_name', - 'hospital', 'category', 'status', 'created_by', 'assigned_to', 'created_at' + "subject_preview", + "patient", + "contact_name", + "hospital", + "category", + "status", + "created_by", + "assigned_to", + "created_at", ] - list_filter = ['status', 'category', 'source', 'hospital', 'created_by', 'created_at'] + list_filter = ["status", "category", "source", "hospital", "created_by", "created_at"] search_fields = [ - 'subject', 'message', 'contact_name', 'contact_phone', - 'patient__mrn', 'patient__first_name', 'patient__last_name' + "subject", + "message", + "contact_name", + "contact_phone", + "patient__mrn", + "patient__first_name", + "patient__last_name", ] - ordering = ['-created_at'] + ordering = ["-created_at"] fieldsets = ( - ('Patient Information', { - 'fields': ('patient',) - }), - ('Contact Information (if no patient)', { - 'fields': ('contact_name', 'contact_phone', 'contact_email'), - 'classes': ('collapse',) - }), - ('Organization', { - 'fields': ('hospital', 'department') - }), - ('Inquiry Details', { - 'fields': ('subject', 'message', 'category', 'source') - }), - ('Creator Tracking', { - 'fields': ('created_by',) - }), - ('Status & Assignment', { - 'fields': ('status', 'assigned_to') - }), - ('Response', { - 'fields': ('response', 'responded_at', 'responded_by') - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + ("Patient Information", {"fields": ("patient",)}), + ( + "Contact Information (if no patient)", + {"fields": ("contact_name", "contact_phone", "contact_email"), "classes": ("collapse",)}, + ), + ("Organization", {"fields": ("hospital", "department")}), + ("Inquiry Details", {"fields": ("subject", "message", "category", "source")}), + ("Creator Tracking", {"fields": ("created_by",)}), + ("Status & Assignment", {"fields": ("status", "assigned_to")}), + ("Response", {"fields": ("response", "responded_at", "responded_by")}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - readonly_fields = ['responded_at', 'created_at', 'updated_at'] + readonly_fields = ["responded_at", "created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related( - 'patient', 'hospital', 'department', - 'assigned_to', 'responded_by', 'created_by' - ) + return qs.select_related("patient", "hospital", "department", "assigned_to", "responded_by", "created_by") def subject_preview(self, obj): """Show preview of subject""" - return obj.subject[:60] + '...' if len(obj.subject) > 60 else obj.subject - subject_preview.short_description = 'Subject' + return obj.subject[:60] + "..." if len(obj.subject) > 60 else obj.subject + + subject_preview.short_description = "Subject" @admin.register(ComplaintSLAConfig) class ComplaintSLAConfigAdmin(admin.ModelAdmin): """Complaint SLA Configuration admin""" - list_display = [ - 'hospital', 'source', 'severity', 'priority', 'sla_hours', - 'reminder_timing_display', 'is_active' - ] - list_filter = ['hospital', 'source', 'severity', 'priority', 'is_active'] - search_fields = ['hospital__name_en', 'hospital__name_ar', 'source__name_en'] - ordering = ['hospital', 'source', 'severity', 'priority'] + + list_display = ["hospital", "source", "severity", "priority", "sla_hours", "reminder_timing_display", "is_active"] + list_filter = ["hospital", "source", "severity", "priority", "is_active"] + search_fields = ["hospital__name_en", "hospital__name_ar", "source__name_en"] + ordering = ["hospital", "source", "severity", "priority"] fieldsets = ( - ('Hospital', { - 'fields': ('hospital',) - }), - ('Source & Classification', { - 'fields': ('source', 'severity', 'priority') - }), - ('SLA Configuration', { - 'fields': ('sla_hours', 'reminder_hours_before') - }), - ('Source-Based Timing (Hours After Creation)', { - 'fields': ( - 'first_reminder_hours_after', - 'second_reminder_hours_after', - 'escalation_hours_after' - ), - 'description': 'When set, these override the "Hours Before Deadline" timing. Used for source-based SLAs (e.g., MOH, CCHI).' - }), - ('Status', { - 'fields': ('is_active',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ("Hospital", {"fields": ("hospital",)}), + ("Source & Classification", {"fields": ("source", "severity", "priority")}), + ("SLA Configuration", {"fields": ("sla_hours", "reminder_hours_before")}), + ( + "Source-Based Timing (Hours After Creation)", + { + "fields": ("first_reminder_hours_after", "second_reminder_hours_after", "escalation_hours_after"), + "description": 'When set, these override the "Hours Before Deadline" timing. Used for source-based SLAs (e.g., MOH, CCHI).', + }, + ), + ("Status", {"fields": ("is_active",)}), + ("Metadata", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), ) - readonly_fields = ['created_at', 'updated_at'] + readonly_fields = ["created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('hospital', 'source') + return qs.select_related("hospital", "source") def reminder_timing_display(self, obj): """Display reminder timing method""" @@ -388,449 +505,364 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin): return format_html( 'Source-based: {0}h / {1}h', obj.first_reminder_hours_after, - obj.second_reminder_hours_after or 'N/A' + obj.second_reminder_hours_after or "N/A", ) elif obj.reminder_hours_before: return format_html( - 'Deadline-based: {0}h before', - obj.reminder_hours_before + 'Deadline-based: {0}h before', obj.reminder_hours_before ) else: - return '—' - reminder_timing_display.short_description = 'Reminder Timing' + return "—" + + reminder_timing_display.short_description = "Reminder Timing" @admin.register(ComplaintCategory) class ComplaintCategoryAdmin(admin.ModelAdmin): """Complaint Category admin""" - list_display = [ - 'name_en', 'code', 'hospitals_display', 'parent', - 'order', 'is_active' - ] - list_filter = ['is_active', 'parent'] - search_fields = ['name_en', 'name_ar', 'code', 'description_en'] - ordering = ['order', 'name_en'] + + list_display = ["name_en", "code", "hospitals_display", "parent", "order", "is_active"] + list_filter = ["is_active", "parent"] + search_fields = ["name_en", "name_ar", "code", "description_en"] + ordering = ["order", "name_en"] fieldsets = ( - ('Hospitals', { - 'fields': ('hospitals',) - }), - ('Category Details', { - 'fields': ('code', 'name_en', 'name_ar') - }), - ('Description', { - 'fields': ('description_en', 'description_ar'), - 'classes': ('collapse',) - }), - ('Hierarchy', { - 'fields': ('parent', 'order') - }), - ('Status', { - 'fields': ('is_active',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ("Hospitals", {"fields": ("hospitals",)}), + ("Category Details", {"fields": ("code", "name_en", "name_ar")}), + ("Description", {"fields": ("description_en", "description_ar"), "classes": ("collapse",)}), + ("Hierarchy", {"fields": ("parent", "order")}), + ("Status", {"fields": ("is_active",)}), + ("Metadata", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), ) - readonly_fields = ['created_at', 'updated_at'] - filter_horizontal = ['hospitals'] + readonly_fields = ["created_at", "updated_at"] + filter_horizontal = ["hospitals"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('parent').prefetch_related('hospitals') + return qs.select_related("parent").prefetch_related("hospitals") def hospitals_display(self, obj): """Display hospitals for category""" hospital_count = obj.hospitals.count() if hospital_count == 0: - return 'System-wide' + return "System-wide" elif hospital_count == 1: return obj.hospitals.first().name else: - return f'{hospital_count} hospitals' - hospitals_display.short_description = 'Hospitals' + return f"{hospital_count} hospitals" + + hospitals_display.short_description = "Hospitals" @admin.register(EscalationRule) class EscalationRuleAdmin(admin.ModelAdmin): """Escalation Rule admin""" - list_display = [ - 'name', 'hospital', 'escalate_to_role', - 'trigger_on_overdue', 'order', 'is_active' - ] + + list_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' + "hospital", + "escalate_to_role", + "trigger_on_overdue", + "severity_filter", + "priority_filter", + "is_active", ] - search_fields = ['name', 'description', 'hospital__name_en'] - ordering = ['hospital', 'order'] + 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',) - }), + ("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'] + readonly_fields = ["created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('hospital', 'escalate_to_user') + 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'] + + 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',) - }), + ("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'] + readonly_fields = ["created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('hospital') + 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' + + comparison_display.short_description = "Comparison" @admin.register(ComplaintPRInteraction) class ComplaintPRInteractionAdmin(admin.ModelAdmin): """PR Interaction admin""" + list_display = [ - 'complaint', 'contact_date', 'contact_method_display', - 'pr_staff', 'procedure_explained', 'created_at' + "complaint", + "contact_date", + "contact_method_display", + "pr_staff", + "procedure_explained", + "created_at", ] - list_filter = [ - 'contact_method', 'procedure_explained', 'created_at' - ] - search_fields = [ - 'complaint__title', 'statement_text', 'notes', - 'pr_staff__first_name', 'pr_staff__last_name' - ] - ordering = ['-contact_date'] + list_filter = ["contact_method", "procedure_explained", "created_at"] + search_fields = ["complaint__title", "statement_text", "notes", "pr_staff__first_name", "pr_staff__last_name"] + ordering = ["-contact_date"] fieldsets = ( - ('Complaint', { - 'fields': ('complaint',) - }), - ('Contact Details', { - 'fields': ('contact_date', 'contact_method', 'pr_staff') - }), - ('Interaction Details', { - 'fields': ('statement_text', 'procedure_explained', 'notes') - }), - ('Metadata', { - 'fields': ('created_by', 'created_at', 'updated_at') - }), + ("Complaint", {"fields": ("complaint",)}), + ("Contact Details", {"fields": ("contact_date", "contact_method", "pr_staff")}), + ("Interaction Details", {"fields": ("statement_text", "procedure_explained", "notes")}), + ("Metadata", {"fields": ("created_by", "created_at", "updated_at")}), ) - readonly_fields = ['created_at', 'updated_at'] + readonly_fields = ["created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('complaint', 'pr_staff', 'created_by') + return qs.select_related("complaint", "pr_staff", "created_by") def contact_method_display(self, obj): """Display contact method""" return obj.get_contact_method_display() - contact_method_display.short_description = 'Method' + + contact_method_display.short_description = "Method" @admin.register(ComplaintMeeting) class ComplaintMeetingAdmin(admin.ModelAdmin): """Complaint Meeting admin""" - list_display = [ - 'complaint', 'meeting_date', 'meeting_type_display', - 'outcome_preview', 'created_by', 'created_at' - ] - list_filter = [ - 'meeting_type', 'created_at' - ] - search_fields = [ - 'complaint__title', 'outcome', 'notes' - ] - ordering = ['-meeting_date'] + + list_display = ["complaint", "meeting_date", "meeting_type_display", "outcome_preview", "created_by", "created_at"] + list_filter = ["meeting_type", "created_at"] + search_fields = ["complaint__title", "outcome", "notes"] + ordering = ["-meeting_date"] fieldsets = ( - ('Complaint', { - 'fields': ('complaint',) - }), - ('Meeting Details', { - 'fields': ('meeting_date', 'meeting_type') - }), - ('Meeting Outcome', { - 'fields': ('outcome', 'notes') - }), - ('Metadata', { - 'fields': ('created_by', 'created_at', 'updated_at') - }), + ("Complaint", {"fields": ("complaint",)}), + ("Meeting Details", {"fields": ("meeting_date", "meeting_type")}), + ("Meeting Outcome", {"fields": ("outcome", "notes")}), + ("Metadata", {"fields": ("created_by", "created_at", "updated_at")}), ) - readonly_fields = ['created_at', 'updated_at'] + readonly_fields = ["created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('complaint', 'created_by') + return qs.select_related("complaint", "created_by") def meeting_type_display(self, obj): """Display meeting type""" return obj.get_meeting_type_display() - meeting_type_display.short_description = 'Type' + + meeting_type_display.short_description = "Type" def outcome_preview(self, obj): """Show preview of outcome""" - return obj.outcome[:100] + '...' if len(obj.outcome) > 100 else obj.outcome - outcome_preview.short_description = 'Outcome' + return obj.outcome[:100] + "..." if len(obj.outcome) > 100 else obj.outcome + + outcome_preview.short_description = "Outcome" @admin.register(ComplaintInvolvedDepartment) class ComplaintInvolvedDepartmentAdmin(admin.ModelAdmin): """Complaint Involved Department admin""" - list_display = [ - 'complaint', 'department', 'role', 'is_primary', - 'assigned_to', 'response_submitted', 'created_at' - ] - list_filter = ['role', 'is_primary', 'response_submitted', 'created_at'] - search_fields = [ - 'complaint__title', 'complaint__reference_number', - 'department__name', 'notes' - ] - ordering = ['-is_primary', '-created_at'] - autocomplete_fields = ['complaint', 'department', 'assigned_to', 'added_by'] + + list_display = ["complaint", "department", "role", "is_primary", "assigned_to", "response_submitted", "created_at"] + list_filter = ["role", "is_primary", "response_submitted", "created_at"] + search_fields = ["complaint__title", "complaint__reference_number", "department__name", "notes"] + ordering = ["-is_primary", "-created_at"] + autocomplete_fields = ["complaint", "department", "assigned_to", "added_by"] fieldsets = ( - ('Complaint', { - 'fields': ('complaint',) - }), - ('Department & Role', { - 'fields': ('department', 'role', 'is_primary') - }), - ('Assignment', { - 'fields': ('assigned_to', 'assigned_at') - }), - ('Response', { - 'fields': ('response_submitted', 'response_submitted_at', 'response_notes') - }), - ('Notes', { - 'fields': ('notes',) - }), - ('Metadata', { - 'fields': ('added_by', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ("Complaint", {"fields": ("complaint",)}), + ("Department & Role", {"fields": ("department", "role", "is_primary")}), + ("Assignment", {"fields": ("assigned_to", "assigned_at")}), + ("Response", {"fields": ("response_submitted", "response_submitted_at", "response_notes")}), + ("Notes", {"fields": ("notes",)}), + ("Metadata", {"fields": ("added_by", "created_at", "updated_at"), "classes": ("collapse",)}), ) - readonly_fields = ['assigned_at', 'response_submitted_at', 'created_at', 'updated_at'] + readonly_fields = ["assigned_at", "response_submitted_at", "created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related( - 'complaint', 'department', 'assigned_to', 'added_by' - ) + return qs.select_related("complaint", "department", "assigned_to", "added_by") @admin.register(ComplaintInvolvedStaff) class ComplaintInvolvedStaffAdmin(admin.ModelAdmin): """Complaint Involved Staff admin""" - list_display = [ - 'complaint', 'staff', 'role', - 'explanation_requested', 'explanation_received', 'created_at' - ] - list_filter = ['role', 'explanation_requested', 'explanation_received', 'created_at'] + + list_display = ["complaint", "staff", "role", "explanation_requested", "explanation_received", "created_at"] + list_filter = ["role", "explanation_requested", "explanation_received", "created_at"] search_fields = [ - 'complaint__title', 'complaint__reference_number', - 'staff__first_name', 'staff__last_name', 'notes' + "complaint__title", + "complaint__reference_number", + "staff__first_name", + "staff__last_name", + "notes", ] - ordering = ['role', '-created_at'] - autocomplete_fields = ['complaint', 'staff', 'added_by'] + ordering = ["role", "-created_at"] + autocomplete_fields = ["complaint", "staff", "added_by"] fieldsets = ( - ('Complaint', { - 'fields': ('complaint',) - }), - ('Staff & Role', { - 'fields': ('staff', 'role') - }), - ('Explanation', { - 'fields': ( - 'explanation_requested', 'explanation_requested_at', - 'explanation_received', 'explanation_received_at', 'explanation' - ) - }), - ('Notes', { - 'fields': ('notes',) - }), - ('Metadata', { - 'fields': ('added_by', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ("Complaint", {"fields": ("complaint",)}), + ("Staff & Role", {"fields": ("staff", "role")}), + ( + "Explanation", + { + "fields": ( + "explanation_requested", + "explanation_requested_at", + "explanation_received", + "explanation_received_at", + "explanation", + ) + }, + ), + ("Notes", {"fields": ("notes",)}), + ("Metadata", {"fields": ("added_by", "created_at", "updated_at"), "classes": ("collapse",)}), ) - readonly_fields = [ - 'explanation_requested_at', 'explanation_received_at', - 'created_at', 'updated_at' - ] + readonly_fields = ["explanation_requested_at", "explanation_received_at", "created_at", "updated_at"] def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related( - 'complaint', 'staff', 'added_by' - ) - + return qs.select_related("complaint", "staff", "added_by") class OnCallAdminInline(admin.TabularInline): """Inline admin for on-call admins""" + model = OnCallAdmin extra = 1 fields = [ - 'admin_user', 'start_date', 'end_date', - 'notification_priority', 'is_active', - 'notify_email', 'notify_sms', 'sms_phone' + "admin_user", + "start_date", + "end_date", + "notification_priority", + "is_active", + "notify_email", + "notify_sms", + "sms_phone", ] - autocomplete_fields = ['admin_user'] - + autocomplete_fields = ["admin_user"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('admin_user') + return qs.select_related("admin_user") @admin.register(OnCallAdminSchedule) class OnCallAdminScheduleAdmin(admin.ModelAdmin): """On-Call Admin Schedule admin""" + list_display = [ - 'hospital_or_system', 'working_hours_display', - 'working_days_display', 'timezone', 'is_active', 'created_at' + "hospital_or_system", + "working_hours_display", + "working_days_display", + "timezone", + "is_active", + "created_at", ] - list_filter = ['is_active', 'timezone', 'created_at'] - search_fields = ['hospital__name'] + list_filter = ["is_active", "timezone", "created_at"] + search_fields = ["hospital__name"] inlines = [OnCallAdminInline] - + fieldsets = ( - ('Scope', { - 'fields': ('hospital', 'is_active') - }), - ('Working Hours Configuration', { - 'fields': ('work_start_time', 'work_end_time', 'timezone', 'working_days'), - 'description': 'Configure working hours. Outside these hours, only on-call admins will be notified.' - }), + ("Scope", {"fields": ("hospital", "is_active")}), + ( + "Working Hours Configuration", + { + "fields": ("work_start_time", "work_end_time", "timezone", "working_days"), + "description": "Configure working hours. Outside these hours, only on-call admins will be notified.", + }, + ), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def hospital_or_system(self, obj): """Display hospital name or 'System-wide'""" if obj.hospital: return obj.hospital.name - return format_html('System-wide') - hospital_or_system.short_description = 'Scope' - hospital_or_system.admin_order_field = 'hospital__name' - + return mark_safe('System-wide') + + hospital_or_system.short_description = "Scope" + hospital_or_system.admin_order_field = "hospital__name" + def working_hours_display(self, obj): """Display working hours""" return f"{obj.work_start_time.strftime('%H:%M')} - {obj.work_end_time.strftime('%H:%M')}" - working_hours_display.short_description = 'Working Hours' - + + working_hours_display.short_description = "Working Hours" + def working_days_display(self, obj): """Display working days as abbreviated day names""" days = obj.get_working_days_list() - day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] selected_days = [day_names[d] for d in days if 0 <= d <= 6] - return ', '.join(selected_days) if selected_days else 'None' - working_days_display.short_description = 'Working Days' - + return ", ".join(selected_days) if selected_days else "None" + + working_days_display.short_description = "Working Days" + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('hospital') + return qs.select_related("hospital") @admin.register(OnCallAdmin) class OnCallAdminAdmin(admin.ModelAdmin): """On-Call Admin admin""" - list_display = [ - 'admin_user', 'schedule', 'notification_priority', - 'date_range', 'contact_preferences', 'is_active' - ] - list_filter = [ - 'is_active', 'notify_email', 'notify_sms', - 'schedule__hospital', 'created_at' - ] - search_fields = [ - 'admin_user__email', 'admin_user__first_name', - 'admin_user__last_name', 'sms_phone' - ] - autocomplete_fields = ['admin_user', 'schedule'] - + + list_display = ["admin_user", "schedule", "notification_priority", "date_range", "contact_preferences", "is_active"] + list_filter = ["is_active", "notify_email", "notify_sms", "schedule__hospital", "created_at"] + search_fields = ["admin_user__email", "admin_user__first_name", "admin_user__last_name", "sms_phone"] + autocomplete_fields = ["admin_user", "schedule"] + fieldsets = ( - ('Assignment', { - 'fields': ('schedule', 'admin_user', 'is_active') - }), - ('Active Period (Optional)', { - 'fields': ('start_date', 'end_date'), - 'description': 'Leave empty for permanent assignment' - }), - ('Notification Settings', { - 'fields': ( - 'notification_priority', 'notify_email', 'notify_sms', 'sms_phone' - ), - 'description': 'Configure how this admin should be notified for after-hours complaints' - }), + ("Assignment", {"fields": ("schedule", "admin_user", "is_active")}), + ( + "Active Period (Optional)", + {"fields": ("start_date", "end_date"), "description": "Leave empty for permanent assignment"}, + ), + ( + "Notification Settings", + { + "fields": ("notification_priority", "notify_email", "notify_sms", "sms_phone"), + "description": "Configure how this admin should be notified for after-hours complaints", + }, + ), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def date_range(self, obj): """Display date range""" if obj.start_date and obj.end_date: @@ -839,144 +871,129 @@ class OnCallAdminAdmin(admin.ModelAdmin): return f"From {obj.start_date}" elif obj.end_date: return f"Until {obj.end_date}" - return format_html('Permanent') - date_range.short_description = 'Active Period' - + return mark_safe('Permanent') + + date_range.short_description = "Active Period" + def contact_preferences(self, obj): """Display contact preferences""" prefs = [] if obj.notify_email: - prefs.append('📧 Email') + prefs.append("📧 Email") if obj.notify_sms: - prefs.append(f'📱 SMS ({obj.sms_phone or "user phone"})') - return ', '.join(prefs) if prefs else 'None' - contact_preferences.short_description = 'Contact' - + prefs.append(f"📱 SMS ({obj.sms_phone or 'user phone'})") + return ", ".join(prefs) if prefs else "None" + + contact_preferences.short_description = "Contact" + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('admin_user', 'schedule', 'schedule__hospital') - + return qs.select_related("admin_user", "schedule", "schedule__hospital") class ComplaintAdverseActionAttachmentInline(admin.TabularInline): """Inline admin for adverse action attachments""" + model = ComplaintAdverseActionAttachment extra = 0 - fields = ['file', 'filename', 'description', 'uploaded_by'] - readonly_fields = ['filename', 'file_size'] + fields = ["file", "filename", "description", "uploaded_by"] + readonly_fields = ["filename", "file_size"] @admin.register(ComplaintAdverseAction) class ComplaintAdverseActionAdmin(admin.ModelAdmin): """Admin for complaint adverse actions""" + list_display = [ - 'complaint_reference', 'action_type_display', 'severity_badge', - 'incident_date', 'status_badge', 'is_escalated', 'created_at' + "complaint_reference", + "action_type_display", + "severity_badge", + "incident_date", + "status_badge", + "is_escalated", + "created_at", ] - list_filter = [ - 'action_type', 'severity', 'status', 'is_escalated', - 'incident_date', 'created_at' - ] - search_fields = [ - 'complaint__reference_number', 'complaint__title', - 'description', 'patient_impact' - ] - date_hierarchy = 'incident_date' + list_filter = ["action_type", "severity", "status", "is_escalated", "incident_date", "created_at"] + search_fields = ["complaint__reference_number", "complaint__title", "description", "patient_impact"] + date_hierarchy = "incident_date" inlines = [ComplaintAdverseActionAttachmentInline] - + fieldsets = ( - ('Complaint Information', { - 'fields': ('complaint',) - }), - ('Adverse Action Details', { - 'fields': ( - 'action_type', 'severity', 'description', - 'incident_date', 'location' - ) - }), - ('Impact & Staff', { - 'fields': ( - 'patient_impact', 'involved_staff' - ) - }), - ('Verification & Investigation', { - 'fields': ( - 'status', 'reported_by', - 'investigation_notes', 'investigated_by', 'investigated_at' - ), - 'classes': ('collapse',) - }), - ('Resolution', { - 'fields': ( - 'resolution', 'resolved_by', 'resolved_at' - ), - 'classes': ('collapse',) - }), - ('Escalation', { - 'fields': ( - 'is_escalated', 'escalated_at' - ) - }), + ("Complaint Information", {"fields": ("complaint",)}), + ("Adverse Action Details", {"fields": ("action_type", "severity", "description", "incident_date", "location")}), + ("Impact & Staff", {"fields": ("patient_impact", "involved_staff")}), + ( + "Verification & Investigation", + { + "fields": ("status", "reported_by", "investigation_notes", "investigated_by", "investigated_at"), + "classes": ("collapse",), + }, + ), + ("Resolution", {"fields": ("resolution", "resolved_by", "resolved_at"), "classes": ("collapse",)}), + ("Escalation", {"fields": ("is_escalated", "escalated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def complaint_reference(self, obj): """Display complaint reference""" return format_html( - '{}', - obj.complaint.id, - obj.complaint.reference_number + '{}', obj.complaint.id, obj.complaint.reference_number ) - complaint_reference.short_description = 'Complaint' - + + complaint_reference.short_description = "Complaint" + def action_type_display(self, obj): """Display action type with formatting""" return obj.get_action_type_display() - action_type_display.short_description = 'Action Type' - + + action_type_display.short_description = "Action Type" + def severity_badge(self, obj): """Display severity as colored badge""" colors = { - 'low': '#22c55e', # green - 'medium': '#f59e0b', # amber - 'high': '#ef4444', # red - 'critical': '#7f1d1d', # dark red + "low": "#22c55e", # green + "medium": "#f59e0b", # amber + "high": "#ef4444", # red + "critical": "#7f1d1d", # dark red } - color = colors.get(obj.severity, '#64748b') + color = colors.get(obj.severity, "#64748b") return format_html( '{}', color, - obj.get_severity_display() + obj.get_severity_display(), ) - severity_badge.short_description = 'Severity' - + + severity_badge.short_description = "Severity" + def status_badge(self, obj): """Display status as colored badge""" colors = { - 'reported': '#f59e0b', - 'under_investigation': '#3b82f6', - 'verified': '#22c55e', - 'unfounded': '#64748b', - 'resolved': '#10b981', + "reported": "#f59e0b", + "under_investigation": "#3b82f6", + "verified": "#22c55e", + "unfounded": "#64748b", + "resolved": "#10b981", } - color = colors.get(obj.status, '#64748b') + color = colors.get(obj.status, "#64748b") return format_html( '{}', color, - obj.get_status_display() + obj.get_status_display(), ) - status_badge.short_description = 'Status' - + + status_badge.short_description = "Status" + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('complaint', 'reported_by', 'investigated_by', 'resolved_by') + return qs.select_related("complaint", "reported_by", "investigated_by", "resolved_by") @admin.register(ComplaintAdverseActionAttachment) class ComplaintAdverseActionAttachmentAdmin(admin.ModelAdmin): """Admin for adverse action attachments""" - list_display = ['adverse_action', 'filename', 'file_type', 'uploaded_by', 'created_at'] - list_filter = ['file_type', 'created_at'] - search_fields = ['filename', 'description', 'adverse_action__complaint__reference_number'] - ordering = ['-created_at'] + + list_display = ["adverse_action", "filename", "file_type", "uploaded_by", "created_at"] + list_filter = ["file_type", "created_at"] + search_fields = ["filename", "description", "adverse_action__complaint__reference_number"] + ordering = ["-created_at"] diff --git a/apps/complaints/management/commands/analyze_complaints_ai.py b/apps/complaints/management/commands/analyze_complaints_ai.py new file mode 100644 index 0000000..56c4ee9 --- /dev/null +++ b/apps/complaints/management/commands/analyze_complaints_ai.py @@ -0,0 +1,237 @@ +""" +Management command to run AI analysis on complaints within a date range. + +This command will find complaints created within a specified date range +and run AI analysis on them. Useful for processing historical complaints +that were imported without AI analysis. + +Usage: + # Analyze complaints from specific date range + python manage.py analyze_complaints_ai --from-date 2022-08-01 --to-date 2022-12-31 + + # Analyze only complaints without existing AI analysis + python manage.py analyze_complaints_ai --from-date 2022-08-01 --to-date 2022-12-31 --skip-analyzed + + # Force re-analysis of all complaints (even those already analyzed) + python manage.py analyze_complaints_ai --from-date 2022-08-01 --to-date 2022-12-31 --force + + # Analyze specific complaints by ID + python manage.py analyze_complaints_ai --complaint-ids uuid1 uuid2 uuid3 + + # Limit number of complaints to analyze + python manage.py analyze_complaints_ai --from-date 2022-08-01 --to-date 2022-12-31 --limit 50 + + # Dry run to see what would be analyzed + python manage.py analyze_complaints_ai --from-date 2022-08-01 --to-date 2022-12-31 --dry-run +""" +import logging +from datetime import datetime, time +from typing import List, Optional + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from apps.complaints.models import Complaint + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Run AI analysis on complaints within a date range" + + def add_arguments(self, parser): + # Date range arguments + parser.add_argument( + '--from-date', + type=str, + help='Start date (YYYY-MM-DD format)' + ) + parser.add_argument( + '--to-date', + type=str, + help='End date (YYYY-MM-DD format)' + ) + + # Specific complaint IDs + parser.add_argument( + '--complaint-ids', + nargs='+', + type=str, + help='Specific complaint UUIDs to analyze' + ) + + # Filtering options + parser.add_argument( + '--skip-analyzed', + action='store_true', + help='Skip complaints that already have AI analysis (default: False)' + ) + parser.add_argument( + '--force', + action='store_true', + help='Force re-analysis even if complaint already has AI analysis' + ) + + # Limit and batch options + parser.add_argument( + '--limit', + type=int, + help='Maximum number of complaints to analyze' + ) + parser.add_argument( + '--batch-size', + type=int, + default=10, + help='Number of complaints to process in each batch (default: 10)' + ) + + # Other options + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be analyzed without actually running analysis' + ) + parser.add_argument( + '--sync', + action='store_true', + help='Run analysis synchronously (not as Celery tasks) - WARNING: May be slow' + ) + + def handle(self, *args, **options): + self.dry_run = options['dry_run'] + self.sync = options['sync'] + self.skip_analyzed = options['skip_analyzed'] + self.force = options['force'] + + # Validate arguments + if not options['complaint_ids'] and (not options['from_date'] or not options['to_date']): + raise CommandError( + "You must provide either --complaint-ids OR both --from-date and --to-date" + ) + + # Build queryset + queryset = self._build_queryset(options) + + if not queryset.exists(): + self.stdout.write(self.style.WARNING("No complaints found matching criteria")) + return + + total_count = queryset.count() + self.stdout.write(f"Found {total_count} complaints to analyze") + + if self.dry_run: + self.stdout.write(self.style.WARNING("\nDRY RUN - Showing first 10 complaints:")) + for complaint in queryset[:10]: + has_analysis = 'Yes' if complaint.metadata and 'ai_analysis' in complaint.metadata else 'No' + self.stdout.write(f" - {complaint.reference_number}: {complaint.title[:50]}... [AI: {has_analysis}]") + if total_count > 10: + self.stdout.write(f" ... and {total_count - 10} more") + return + + # Process complaints + self._process_complaints(queryset, options.get('batch_size', 10)) + + def _build_queryset(self, options): + """Build queryset based on options.""" + + # Start with specific IDs or date range + if options['complaint_ids']: + queryset = Complaint.objects.filter(id__in=options['complaint_ids']) + else: + # Parse dates + try: + from_date = datetime.strptime(options['from_date'], '%Y-%m-%d') + to_date = datetime.strptime(options['to_date'], '%Y-%m-%d') + # Set to end of day for to_date + to_date = to_date.replace(hour=23, minute=59, second=59) + except ValueError: + raise CommandError("Dates must be in YYYY-MM-DD format") + + # Make timezone aware + from_date = timezone.make_aware(from_date) + to_date = timezone.make_aware(to_date) + + queryset = Complaint.objects.filter( + created_at__gte=from_date, + created_at__lte=to_date + ) + + # Apply filters + if self.skip_analyzed and not self.force: + # Skip complaints that already have AI analysis + queryset = queryset.exclude( + metadata__has_key='ai_analysis' + ) + elif not self.force: + # By default, skip analyzed unless force is specified + queryset = queryset.exclude( + metadata__has_key='ai_analysis' + ) + + # Apply limit + if options.get('limit'): + queryset = queryset[:options['limit']] + + return queryset + + def _process_complaints(self, queryset, batch_size): + """Process complaints in batches.""" + total = queryset.count() + processed = 0 + success = 0 + failed = 0 + skipped = 0 + + self.stdout.write(f"\nProcessing {total} complaints...") + self.stdout.write("=" * 80) + + for complaint in queryset.iterator(): + processed += 1 + + # Show progress + if processed % 10 == 0 or processed == 1: + self.stdout.write(f"\nProgress: {processed}/{total} ({(processed/total)*100:.1f}%)") + + try: + # Check if already analyzed (unless force) + if not self.force and complaint.metadata and 'ai_analysis' in complaint.metadata: + self.stdout.write(f" Skipping {complaint.reference_number}: Already analyzed") + skipped += 1 + continue + + self.stdout.write(f" Analyzing {complaint.reference_number}...", ending='') + + if self.sync: + # Run synchronously + from .tasks import analyze_complaint_with_ai + result = analyze_complaint_with_ai(str(complaint.id)) + if result and result.get('status') == 'success': + self.stdout.write(self.style.SUCCESS(" OK")) + success += 1 + else: + self.stdout.write(self.style.ERROR(" FAILED")) + failed += 1 + else: + # Queue as Celery task + from .tasks import analyze_complaint_with_ai + analyze_complaint_with_ai.delay(str(complaint.id)) + self.stdout.write(self.style.SUCCESS(" QUEUED")) + success += 1 + + except Exception as e: + self.stdout.write(self.style.ERROR(f" ERROR: {str(e)}")) + logger.error(f"Error analyzing complaint {complaint.id}: {e}", exc_info=True) + failed += 1 + + # Print summary + self.stdout.write("\n" + "=" * 80) + self.stdout.write(self.style.SUCCESS("ANALYSIS COMPLETE")) + self.stdout.write("=" * 80) + self.stdout.write(f"Total complaints: {total}") + self.stdout.write(self.style.SUCCESS(f"Successfully queued/processed: {success}")) + self.stdout.write(self.style.WARNING(f"Skipped (already analyzed): {skipped}")) + self.stdout.write(self.style.ERROR(f"Failed: {failed}")) + + if not self.sync: + self.stdout.write("\nNote: Analysis is running asynchronously via Celery.") + self.stdout.write("Check Celery worker logs for progress.") diff --git a/apps/complaints/management/commands/complaint_taxonomy_mapping.py b/apps/complaints/management/commands/complaint_taxonomy_mapping.py new file mode 100644 index 0000000..8c6f8f2 --- /dev/null +++ b/apps/complaints/management/commands/complaint_taxonomy_mapping.py @@ -0,0 +1,362 @@ +""" +Taxonomy mapping configuration for historical complaint import (2022). + +Maps Excel classification names to ComplaintCategory UUIDs. +""" + +import re + + +# Domain mappings (Level 1) - Maps Excel Domain → ComplaintCategory UUID +DOMAIN_MAPPING = { + "Clinical complaints": "d7bb3c2d-9f80-4d8c-85dc-ca5ac139417a", # CLINICAL / سريري + "Clinical Care": "51d3e74b-7c7f-48aa-98ef-475f97a47d0d", # الرعاية السريرية + "Relationship complaints": "f8263c2e-a89b-4ab7-bccd-a1524d85524c", # RELATIONSHIPS / علاقات + "Relationships": "73f83d5f-81a1-4340-970c-46d2950e7c98", # العلاقات + "Management complaints": "a58cb0ee-4622-4996-99ad-e134b88687e6", # MANAGEMENT / إداري + "Management": "70f95ca0-436f-4a1f-9ea5-9de2625e70c9", # الإدارة +} + +# Category mappings (Level 2) - Maps Excel Category → ComplaintCategory UUID +CATEGORY_MAPPING = { + "Quality": "416a4c10-5739-4e27-a62f-ea5f107f0e81", # الجودة + "1. Quality": "416a4c10-5739-4e27-a62f-ea5f107f0e81", # الجودة + "Safety": "29e47b23-a724-46da-b128-b7ceb7546da0", # السلامة + "2. Safety": "29e47b23-a724-46da-b128-b7ceb7546da0", # السلامة + "Institutional Issues": "ca1619c9-7df4-4c97-b216-7a8113917997", # القضايا المؤسسية + "3. Institutional issues": "ca1619c9-7df4-4c97-b216-7a8113917997", # القضايا المؤسسية + "Accessibility": "313679ed-9749-4e6c-9f92-9c9a4126f6bf", # سهولة الوصول + "4. Accessibility": "313679ed-9749-4e6c-9f92-9c9a4126f6bf", # سهولة الوصول + "Communication": "51b66802-59c8-42db-9f04-b7d468bc2408", # التواصل + "5. Communication": "51b66802-59c8-42db-9f04-b7d468bc2408", # التواصل + "Humanness / Caring": "7409fc81-8d66-4028-aa1b-8b3143c39b46", # الإنسانية / الرعاية + "6. Humanness / Caring": "7409fc81-8d66-4028-aa1b-8b3143c39b46", # الإنسانية / الرعاية + "Confidentiality": "6a2d6d89-fdcb-425a-97a0-f3e524d0090b", # الخصوصية + "Consent": "54f695ca-4050-4b23-a463-ce4b136db173", # الموافقة + "4. Finance and Billing": "7f87c9c4-68df-48e7-81a4-4bebd7376253", # المالية والفواتير + "5. Incorrect Information": "6e75d91a-5c1e-4f1b-a71b-ba90b7a2ba5b", # معلومات غير صحيحة + "6. Confidentiality": "6a2d6d89-fdcb-425a-97a0-f3e524d0090b", # الخصوصية +} + +# Sub-Category mappings (Level 3) - Maps Excel Sub-Category → ComplaintCategory UUID +SUBCATEGORY_MAPPING = { + "Examination": "a173a340-9dee-4115-a901-b4555e546500", # الفحص + "1.1. Examination": "a173a340-9dee-4115-a901-b4555e546500", # الفحص + "Patient Journey": "23be662b-ae71-43c3-9a19-09388c903468", # رحلة المريض + "Patient journey": "23be662b-ae71-43c3-9a19-09388c903468", # رحلة المريض + "1.2.Patient journey": "23be662b-ae71-43c3-9a19-09388c903468", # رحلة المريض + "Quality of Care": "bd4e9fea-cfe9-4a2d-9c34-0dcfad39129e", # جودة الرعاية + "1.3.Quality of Care": "bd4e9fea-cfe9-4a2d-9c34-0dcfad39129e", # جودة الرعاية + "Treatment": "df5c295d-d04a-4260-a20e-ef2e9967d84f", # العلاج + "1.4.Treatment": "df5c295d-d04a-4260-a20e-ef2e9967d84f", # العلاج + "Diagnosis": "9335435e-98fb-48cd-b153-102ce4f5a39d", # التشخيص + "1.5.Diagnosis": "9335435e-98fb-48cd-b153-102ce4f5a39d", # التشخيص + "Medication & Vaccination": "287ba991-305b-4585-bd3a-1bf2c8b19397", # الأدوية واللقاحات + "Medication and Vaccination": "287ba991-305b-4585-bd3a-1bf2c8b19397", # الأدوية واللقاحات + "2.1.Medication & Vaccination": "287ba991-305b-4585-bd3a-1bf2c8b19397", # الأدوية واللقاحات + "Safety Incidents": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", # حوادث السلامة + "2.2.Safety Incidents": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", # حوادث السلامة + "Skills and Conduct": "cb7032f8-cb95-4d2c-81d6-d1f0e4119800", # المهارات والسلوك + "2.3.Skills and Conduct": "cb7032f8-cb95-4d2c-81d6-d1f0e4119800", # المهارات والسلوك + "Administrative Policies": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", # السياسات الإدارية + "3.1.Administrative Policies and Procedures": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", # السياسات الإدارية + "Environment": "7108f73c-300e-4212-939f-64264721888c", # البيئة + "3.2.Environment": "7108f73c-300e-4212-939f-64264721888c", # البيئة + "Safety & Security": "86f5fdac-df2a-4d4a-a97a-47812e14489a", # الأمن والسلامة + "3.3.Safety & Security": "86f5fdac-df2a-4d4a-a97a-47812e14489a", # الأمن والسلامة + "Finance and Billing": "7f87c9c4-68df-48e7-81a4-4bebd7376253", # المالية والفواتير + "3.4.Finance and Billing": "7f87c9c4-68df-48e7-81a4-4bebd7376253", # المالية والفواتير + "Resources": "2b67dc2f-b7d8-48a8-8e8c-4f90a571c414", # الموارد + "3.6.Resources": "2b67dc2f-b7d8-48a8-8e8c-4f90a571c414", # الموارد + "Access": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", # الوصول + "4.1.Access": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", # الوصول + "Delays": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", # التأخير + "4.3.Delays": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", # التأخير + "Patient-staff communication": "b1ef5b10-dc5a-49fc-9fc6-277ca4d41609", # التواصل بين المريض والموظفين + "5.1.Patient-staff Communication": "b1ef5b10-dc5a-49fc-9fc6-277ca4d41609", # التواصل بين المريض والموظفين + "Emotional Support": "51087d24-2803-448e-a9dc-aba310fcc15b", # الدعم العاطفي + "6.1.Emotional Support": "51087d24-2803-448e-a9dc-aba310fcc15b", # الدعم العاطفي + "Assault and Harassment": "5721210d-0294-4356-ab7d-eebe4e5ab9d7", # الاعتداء والمضايقة + "6.2.Assault and Harassment": "5721210d-0294-4356-ab7d-eebe4e5ab9d7", # الاعتداء والمضايقة + "6.2.Assault and Harassment": "5721210d-0294-4356-ab7d-eebe4e5ab9d7", # الاعتداء والمضايقة + "Consent Process": "e6ff3ace-a4e6-4d44-a7f4-178b846b632c", # إجراءات الموافقة + "Privacy": "68d32c04-6c99-40b5-949d-10e78becbb99", # خصوصية المعلومات + "6.3.Confidentiality": "68d32c04-6c99-40b5-949d-10e78becbb99", # خصوصية المعلومات + "3.5.Staffing": "2b67dc2f-b7d8-48a8-8e8c-4f90a571c414", # الموارد + "4.2.Patient Disposition (final plan)": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", # التأخير + "4.4.Referrals": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", # الوصول + "3.7.Medical records": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", # السياسات الإدارية + "6.4.Consent": "e6ff3ace-a4e6-4d44-a7f4-178b846b632c", # إجراءات الموافقة + "5.2.Incorrect Information": "6e75d91a-5c1e-4f1b-a71b-ba90b7a2ba5b", # معلومات غير صحيحة +} + +# Classification mappings (Level 4) - Maps Excel Classification → ComplaintCategory UUID +CLASSIFICATION_MAPPING = { + "Patient flow issues": "8488fbf4-610e-4754-b2bb-096a67c4305c", + "1.2.2.Patient flow issues": "8488fbf4-610e-4754-b2bb-096a67c4305c", + "Substandard clinical/nursing care": "884bace8-9402-456c-9ced-1f140e9938c0", + "1.3.1.Substandard clinical/nursing care": "884bace8-9402-456c-9ced-1f140e9938c0", + "Errors in diagnosis": "3ab515e6-0504-48b6-8c83-dab0475ac331", + "1.5.1.Errors in diagnosis": "3ab515e6-0504-48b6-8c83-dab0475ac331", + "Dispensing errors": "23af36a6-3050-4fe4-bb30-d5d61abccb7a", + "2.1.2.Dispensing errors": "23af36a6-3050-4fe4-bb30-d5d61abccb7a", + # 'Calculate Additional amount': 'NOT-IN-SYSTEM', # Not found in current taxonomy, + # '3.4.3.Calculate Additional amount': 'NOT-IN-SYSTEM', # Not found in current taxonomy, + "Unnecessary health services": "24433748-9854-4cfe-9e32-65f4fb70c5c1", + "3.4.6.Unnecessary health services": "24433748-9854-4cfe-9e32-65f4fb70c5c1", + "Examination delay": "af9f4297-7094-416b-a9b2-b5d1b3f212c3", # Examination delay in emergency, + "4.3.3.Examination delay": "af9f4297-7094-416b-a9b2-b5d1b3f212c3", # Examination delay in emergency, + "Miscommunication with Patient": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "5.1.1.Miscommunication with Patient": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "Inappropriate/aggressive behavior": "9767593f-9be0-481f-8520-d5e855de3d8b", + "6.2.1.Inappropriate/aggressive behavior": "9767593f-9be0-481f-8520-d5e855de3d8b", + "Inadequate emotional support": "51087d24-2803-448e-a9dc-aba310fcc15b", + "6.1.1.Inadequate emotional support": "51087d24-2803-448e-a9dc-aba310fcc15b", + "Poor provider-patient communication": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "5.1.2.Poor provider-patient communication": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "Neglect": "51087d24-2803-448e-a9dc-aba310fcc15b", + "6.1.2.Neglect": "51087d24-2803-448e-a9dc-aba310fcc15b", + "1.2.Neglect": "23be662b-ae71-43c3-9a19-09388c903468", + "Miscoordination": "23be662b-ae71-43c3-9a19-09388c903468", + "1.2.1.Miscoordination": "23be662b-ae71-43c3-9a19-09388c903468", + "Lack of follow up": "23be662b-ae71-43c3-9a19-09388c903468", + "1.2.3.Lack of follow up": "23be662b-ae71-43c3-9a19-09388c903468", + "Rough treatment": "bd4e9fea-cfe9-4a2d-9c34-0dcfad39129e", + "1.3.3.Rough treatment": "bd4e9fea-cfe9-4a2d-9c34-0dcfad39129e", + "Insensitive to patient needs": "bd4e9fea-cfe9-4a2d-9c34-0dcfad39129e", + "1.3.4.Insensitive to patient needs": "bd4e9fea-cfe9-4a2d-9c34-0dcfad39129e", + "Inadequate/incomplete assessment": "884bace8-9402-456c-9ced-1f140e9938c0", + "1.1.2. Inadequate/incomplete assessment": "884bace8-9402-456c-9ced-1f140e9938c0", + "2. Inadequate/incomplete assessment": "884bace8-9402-456c-9ced-1f140e9938c0", + "Examination not performed": "a173a340-9dee-4115-a901-b4555e546500", + "1.1.1. Examination not performed": "a173a340-9dee-4115-a901-b4555e546500", + "Lab tests not performed": "a173a340-9dee-4115-a901-b4555e546500", + "1.1.4. Lab tests not performed": "a173a340-9dee-4115-a901-b4555e546500", + "Diagnostic Imaging not performed": "a173a340-9dee-4115-a901-b4555e546500", + "1.1.5. Diagnostic Imaging not performed": "a173a340-9dee-4115-a901-b4555e546500", + "Loss of a patient sample": "a173a340-9dee-4115-a901-b4555e546500", + "1.1.6.Loss of a patient sample": "a173a340-9dee-4115-a901-b4555e546500", + "Not having enough knowledge regarding the patient condition": "a173a340-9dee-4115-a901-b4555e546500", + "1.1.3. Not having enough knowledge regarding the patient condition": "a173a340-9dee-4115-a901-b4555e546500", + "Treatment plan issues": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "1.4.1.Treatment plan issues": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "Treatment plan not followed": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "1.4.2 Treatment plan not followed": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "Ineffective treatment": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "1.4.3.Ineffective treatment": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "Inadequate pain management": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "1.4.4.Inadequate pain management": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "Patient Discharged before completing treatment": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "1.4.5.Patient Discharged before completing treatment": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "Prescribing errors": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.1.Prescribing errors": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "No medication prescribed": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.3.No medication prescribed": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Dispensing medication without prescription": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.5.Dispensing medication without prescription": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Prescription of expired medication": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.6.Prescription of expired medication": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Medication shortages": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.8.Medication shortages": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Insufficient medication prescribed": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.4 Insufficient medication prescribed": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Errors in lab results": "9335435e-98fb-48cd-b153-102ce4f5a39d", + "1.5.2.Errors in lab results": "9335435e-98fb-48cd-b153-102ce4f5a39d", + "Errors in Pre-marriage lab test": "9335435e-98fb-48cd-b153-102ce4f5a39d", + "1.5.4.Errors in Pre-marriage lab test": "9335435e-98fb-48cd-b153-102ce4f5a39d", + "Equipment failure/malfunction": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "1.Equipment failure/malfunction": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "2.2.1.Equipment failure/malfunction": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "11.Heating, Ventilation, Air condition (HVAC) Failure": "aeda737e-3d7f-49d5-afa7-9b79f94a049f", + "Wrong treatment": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "2.2.4.Wrong treatment": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "Poor hand-hygiene": "cb7032f8-cb95-4d2c-81d6-d1f0e4119800", + "2.3.4.Poor hand-hygiene": "cb7032f8-cb95-4d2c-81d6-d1f0e4119800", + "Improper practice of infection control recommendation": "cb7032f8-cb95-4d2c-81d6-d1f0e4119800", + "2.3.5.Improper practice of infection control recommendation": "cb7032f8-cb95-4d2c-81d6-d1f0e4119800", + "Paperwork delays": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.1.1.Paperwork delays": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Facility guidelines compliance": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.1.3.Facility guidelines compliance": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Required Service not obtained": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.1.4.Required Service not obtained": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Non-compliance with visiting hours policy": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.1.7.Non-compliance with visiting hours policy": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Inadequate reception service": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.1.8.Inadequate reception service": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Inadequate call center service": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.1.9.Inadequate call center service": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Poor environment": "7108f73c-300e-4212-939f-64264721888c", + "3.2.1.Poor environment": "7108f73c-300e-4212-939f-64264721888c", + "Poor cleanliness/sanitizing": "7108f73c-300e-4212-939f-64264721888c", + "3.2.2.Poor cleanliness/sanitizing": "7108f73c-300e-4212-939f-64264721888c", + "Poor Food service": "7108f73c-300e-4212-939f-64264721888c", + "2.4.Poor Food service": "7108f73c-300e-4212-939f-64264721888c", + "3.2.4.Poor Food service": "7108f73c-300e-4212-939f-64264721888c", + "Poor security response": "86f5fdac-df2a-4d4a-a97a-47812e14489a", + "3.3.2.Poor security response": "86f5fdac-df2a-4d4a-a97a-47812e14489a", + "Theft and lost": "86f5fdac-df2a-4d4a-a97a-47812e14489a", + "3.3.8.Theft and lost": "86f5fdac-df2a-4d4a-a97a-47812e14489a", + "Lack of parking slots": "86f5fdac-df2a-4d4a-a97a-47812e14489a", + "3.3.9.Lack of parking slots": "86f5fdac-df2a-4d4a-a97a-47812e14489a", + "Miscalculation": "7f87c9c4-68df-48e7-81a4-4bebd7376253", + "3.4.2.Miscalculation": "7f87c9c4-68df-48e7-81a4-4bebd7376253", + "Pricing variations": "7f87c9c4-68df-48e7-81a4-4bebd7376253", + "3.4.5.Pricing variations": "7f87c9c4-68df-48e7-81a4-4bebd7376253", + "Unavailable Beds": "2b67dc2f-b7d8-48a8-8e8c-4f90a571c414", + "3.6.5.Unavailable Beds": "2b67dc2f-b7d8-48a8-8e8c-4f90a571c414", + "Sick leave issues": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.7.7.Sick leave issues": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Birth registry issues": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.7.4.Birth registry issues": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.7.5.Death registry issues": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.7.6.Lab results issues": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Medical report issues": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.7.3.Medical report issues": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Incorrect medical records": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "3.7.2.Incorrect medical records": "30ff5d01-cd94-49b4-9d62-e10ffba5faca", + "Poor availability and scheduling": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", + "2.Poor availability and scheduling": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", + "4.1.2.Poor availability and scheduling": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", + "Appointment scheduling refusal": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", + "4.1.1.Appointment scheduling refusal": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", + "Appointment delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.1.4.Appointment delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Appointment cancellation": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.1.5.Appointment cancellation": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Scheduling errors": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.1.7.Scheduling errors": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Patient admission refusal": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.1.3.Patient admission refusal": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Delay in admitting patient": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.3.1.Delay in admitting patient": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Examination delay in emergency": "af9f4297-7094-416b-a9b2-b5d1b3f212c3", + "Diagnosis delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.3.4.Diagnosis delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Delayed test result": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.3.5.Delayed test result": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Treatment delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.3.6.Treatment delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Surgical intervention delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.3.7.Surgical intervention delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Vaccinating delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.3.8.Vaccinating delay": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Delay in discharging patient": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.3.9.Delay in discharging patient": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "Communication of wrong information": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "5.2.2.Communication of wrong information": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "Deficient Information": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "5.2.1.Deficient Information": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "2.1.Deficient Information": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Not involving patient in clinical decisions": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "5.1.3.Not involving patient in clinical decisions": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "Failure to clarify patient case to his family": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "5.1.4.Failure to clarify patient case to his family": "17c5f8f1-e4c2-477a-a4d0-d4253434d22e", + "Breach of patient privacy": "98ededd1-d7c3-440a-805e-addcedf1cb41", + "6.3.2.Breach of patient privacy": "98ededd1-d7c3-440a-805e-addcedf1cb41", + "No apology to the patient": "51087d24-2803-448e-a9dc-aba310fcc15b", + "6.2.5.No apology to the patient": "51087d24-2803-448e-a9dc-aba310fcc15b", + "Reimbursements issues": "7f87c9c4-68df-48e7-81a4-4bebd7376253", + "3.4.4.Reimbursements issues": "7f87c9c4-68df-48e7-81a4-4bebd7376253", + "Specialty not available": "2b67dc2f-b7d8-48a8-8e8c-4f90a571c414", + "3.5.2.Specialty not available": "2b67dc2f-b7d8-48a8-8e8c-4f90a571c414", + "Patient death": "998e7952-ad30-432e-9631-e4493ed8aaa1", + "2.2.18.Patient death": "998e7952-ad30-432e-9631-e4493ed8aaa1", + "Labor and delivery related issues": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "2.2.10.Labor and delivery related issues": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "Wrong surgery": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "2.2.11.Wrong surgery": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "Surgical complications": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "2.2.13.Surgical complications": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "Complications resulting from treatment": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "2.2.5.Complications resulting from treatment": "df5c295d-d04a-4260-a20e-ef2e9967d84f", + "Vaccination shortages": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.10.Vaccination shortages": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Vaccinations timing": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.11.Vaccinations timing": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Refusal to vaccinate": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.12.Refusal to vaccinate": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Refusal to dispense medications": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "2.1.9.Refusal to dispense medications": "287ba991-305b-4585-bd3a-1bf2c8b19397", + "Rushed, not time to see patients": "bd4e9fea-cfe9-4a2d-9c34-0dcfad39129e", + "1.3.5.Rushed, not time to see patients": "bd4e9fea-cfe9-4a2d-9c34-0dcfad39129e", + "Medical device failure": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "2.2.2.Medical device failure": "1faf00f0-9e44-4bc8-a83c-c1a3dcb89041", + "Calculate Additional amount": "7f87c9c4-68df-48e7-81a4-4bebd7376253", + "3.4.3.Calculate Additional amount": "7f87c9c4-68df-48e7-81a4-4bebd7376253", + "Patient referral refusal": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", + "4.4.1.Patient referral refusal": "b603082c-0e6a-420b-8f99-7ad8a5f9bbc8", + "Delay in patient transfer": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", + "4.4.2.Delay in patient transfer": "00fcedd7-5de7-44dc-bf72-8fa62bbfcf4b", +} + + +def _normalize_name(name: str) -> str: + """Normalize category name: replace newlines with spaces, collapse multiple spaces, strip.""" + return re.sub(r"\s+", " ", name.strip()) if name else "" + + +def get_mapped_category(name: str, mapping: dict) -> str: + """ + Get UUID for a category name from the mapping. + + Args: + name: The Excel category name + mapping: The appropriate mapping dictionary + + Returns: + UUID string or None if not mapped + """ + if not name: + return None + + normalized = _normalize_name(name) + + # Try exact match + if normalized in mapping: + return mapping[normalized] + + # Try with stripped number prefix (e.g., "1. Quality" -> "Quality", ".1. Quality" -> "Quality") + stripped_name = re.sub(r"^[.\d]+\s*", "", normalized).strip() + if stripped_name in mapping: + return mapping[stripped_name] + + # Try case-insensitive match + normalized_lower = normalized.lower() + for key, value in mapping.items(): + if _normalize_name(key).lower() == normalized_lower: + return value + + # Try case-insensitive with stripped name + stripped_lower = stripped_name.lower() + for key, value in mapping.items(): + if _normalize_name(re.sub(r"^[.\d]+\s*", "", key)).lower() == stripped_lower: + return value + + return None + + +def is_taxonomy_mapped(domain: str, category: str, subcategory: str, classification: str) -> bool: + """ + Check if all taxonomy levels are mapped. + + Args: + domain: Domain name from Excel + category: Category name from Excel + subcategory: Sub-category name from Excel + classification: Classification name from Excel + + Returns: + True if all levels are mapped, False otherwise + """ + # Check each level - if name exists but not mapped, return False + if domain and not get_mapped_category(domain, DOMAIN_MAPPING): + return False + if category and not get_mapped_category(category, CATEGORY_MAPPING): + return False + if subcategory and not get_mapped_category(subcategory, SUBCATEGORY_MAPPING): + return False + if classification and not get_mapped_category(classification, CLASSIFICATION_MAPPING): + return False + + return True diff --git a/apps/complaints/management/commands/import_2025_complaints_basic.py b/apps/complaints/management/commands/import_2025_complaints_basic.py new file mode 100644 index 0000000..9c0f91e --- /dev/null +++ b/apps/complaints/management/commands/import_2025_complaints_basic.py @@ -0,0 +1,302 @@ +""" +Import 2025 complaints from Excel with basic fields (no AI, skip missing columns). + +2025 has different structure than 2022-2024: +- No 4-level taxonomy (skip) +- No Staff ID column (use staff_name text only) +- No Rightful Side column (skip) + +Usage: + python manage.py import_2025_complaints_basic "Complaints Report - 2025.xlsx" --sheet="JAN" +""" +import logging +import re +from datetime import datetime +from typing import Dict, Optional + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from apps.accounts.models import User +from apps.complaints.models import Complaint +from apps.organizations.models import Hospital, Location, MainSection, SubSection + +logger = logging.getLogger(__name__) + +DEFAULT_HOSPITAL_CODE = "NUZHA-DEV" + +# 2025 Column mapping (different from 2022-2024) +COLUMN_MAPPING = { + "complaint_num": 3, # رقم الشكوى + "mrn": 4, # رقم الملف + "source": 5, # جهة الشكوى + "location_name": 6, # الموقع + "main_dept_name": 7, # القسم الرئيس + "sub_dept_name": 8, # القسم الفرعي + "date_received": 9, # تاريخ إستلام الشكوى + "data_entry_person": 10, # المدخل + "response_date": 48, # تاريخ الرد (was Staff ID in 2022-2024) + "staff_name": 51, # اسم الشخص المشتكى عليه (was col 49) + # Skip cols 52-53 (Complain Classification, Main Subject) + "description_ar": 54, # محتوى الشكوى (عربي) + "description_en": 55, # محتوى الشكوى (English) + "satisfaction": 56, # توثيق تذكيرات للقسم المشتكى عليه + "reminder_date": 57, # تاريخ التذكير +} + +# Month mapping for 2025 sheet names (3-letter abbreviations) +MONTH_MAP = { + "JAN": "01", "FEB": "02", "MAR": "03", "APR": "04", + "MAY": "05", "JUN": "06", "JUL": "07", "AUG": "08", + "SEP": "09", "OCT": "10", "NOV": "11", "DEC": "12", +} + + +class Command(BaseCommand): + help = "Import 2025 complaints with basic fields (no taxonomy, no staff linking)" + + def add_arguments(self, parser): + parser.add_argument("excel_file", type=str) + parser.add_argument("--sheet", type=str, default="JAN") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--start-row", type=int, default=3) + + def handle(self, *args, **options): + self.excel_file = options["excel_file"] + self.sheet_name = options["sheet"] + self.dry_run = options["dry_run"] + self.start_row = options["start_row"] + + # Load hospital + self.hospital = self._load_hospital() + if not self.hospital: + raise CommandError(f'Hospital "{DEFAULT_HOSPITAL_CODE}" not found') + + self.stdout.write(f"Using hospital: {self.hospital.name}") + + # Load Excel + try: + import openpyxl + self.wb = openpyxl.load_workbook(self.excel_file) + except ImportError: + raise CommandError("openpyxl required: pip install openpyxl") + + if self.sheet_name not in self.wb.sheetnames: + available = ", ".join(self.wb.sheetnames) + raise CommandError(f'Sheet "{self.sheet_name}" not found. Available: {available}') + + self.ws = self.wb[self.sheet_name] + self.stdout.write(f"Processing sheet: {self.sheet_name}") + + # Stats + self.stats = {"processed": 0, "success": 0, "failed": 0} + self.errors = [] + + # Process + self._process_sheet() + self._print_report() + + def _load_hospital(self) -> Optional[Hospital]: + try: + return Hospital.objects.get(code=DEFAULT_HOSPITAL_CODE) + except Hospital.DoesNotExist: + return None + + def _process_sheet(self): + row_num = self.start_row + + while row_num <= self.ws.max_row: + try: + row_data = self._extract_row_data(row_num) + + if not row_data.get("complaint_num"): + row_num += 1 + continue + + self.stats["processed"] += 1 + + # Build reference number + ref_num = self._build_reference_number(row_data["complaint_num"]) + + # Check for duplicate + if Complaint.objects.filter(reference_number=ref_num).exists(): + row_num += 1 + continue + + # Parse dates + date_received = self._parse_datetime(row_data.get("date_received")) + created_at = date_received or timezone.now() + response_date = self._parse_datetime(row_data.get("response_date")) + reminder_date = self._parse_datetime(row_data.get("reminder_date")) + + # Resolve location/departments + location = self._resolve_location(row_data.get("location_name")) + main_section = self._resolve_section(row_data.get("main_dept_name")) + subsection = self._resolve_subsection(row_data.get("sub_dept_name")) + + # Get/create data entry user + assigned_to_user = self._get_or_create_data_entry_user(row_data.get("data_entry_person")) + + # Determine status + status = "open" + if response_date: + status = "resolved" + + if not self.dry_run: + with transaction.atomic(): + complaint = Complaint.objects.create( + reference_number=ref_num, + hospital=self.hospital, + location=location, + main_section=main_section, + subsection=subsection, + title=self._build_title(row_data), + description=self._build_description(row_data), + patient_name="Unknown", + national_id="", + relation_to_patient="patient", + staff=None, # No staff linking for 2025 + staff_name=row_data.get("staff_name") or "", + # No taxonomy fields for 2025 + domain=None, + category=None, + subcategory_obj=None, + classification_obj=None, + status=status, + assigned_to=assigned_to_user, + resolved_by=assigned_to_user if response_date else None, + # Timeline + created_at=created_at, + explanation_requested=bool(date_received), + explanation_requested_at=date_received, + explanation_received_at=response_date, + reminder_sent_at=reminder_date, + metadata={ + "import_source": "2025_excel_basic", + "original_sheet": self.sheet_name, + "complaint_num": row_data.get("complaint_num"), + }, + ) + + self.stats["success"] += 1 + + except Exception as e: + self.stats["failed"] += 1 + self.errors.append({"row": row_num, "error": str(e)}) + logger.error(f"Row {row_num}: {e}", exc_info=True) + + row_num += 1 + + def _extract_row_data(self, row_num: int) -> Dict: + data = {} + for field, col in COLUMN_MAPPING.items(): + cell_value = self.ws.cell(row_num, col).value + data[field] = cell_value + return data + + def _build_reference_number(self, complaint_num) -> str: + sheet_parts = self.sheet_name.strip().split() + year = "2025" + month_part = sheet_parts[0].upper() + month_code = MONTH_MAP.get(month_part, "00") + return f"CMP-{year}-{month_code}-{int(complaint_num):04d}" + + def _parse_datetime(self, value) -> Optional[datetime]: + if not value: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + except ValueError: + try: + return datetime.strptime(value, "%Y-%m-%d") + except ValueError: + return None + return None + + def _resolve_location(self, name_ar: str) -> Optional[Location]: + if not name_ar: + return None + return Location.objects.filter(name_ar=name_ar).first() + + def _resolve_section(self, name_ar: str) -> Optional[MainSection]: + if not name_ar: + return None + return MainSection.objects.filter(name_ar=name_ar).first() + + def _resolve_subsection(self, name_ar: str) -> Optional[SubSection]: + if not name_ar: + return None + return SubSection.objects.filter(name_ar=name_ar).first() + + def _get_or_create_data_entry_user(self, arabic_name: str) -> Optional[User]: + if not arabic_name: + return None + + try: + from unidecode import unidecode + except ImportError: + return None + + parts = arabic_name.split() + if len(parts) >= 2: + first, last = parts[0], parts[-1] + else: + first, last = arabic_name, "coordinator" + + username_first = re.sub(r"[^a-z0-9]", "", unidecode(first).lower().strip()) + username_last = re.sub(r"[^a-z0-9]", "", unidecode(last).lower().strip()) + + if not username_first: + username_first = "user" + if not username_last: + username_last = "coordinator" + + username = f"{username_first}.{username_last}" + + user = User.objects.filter(username=username).first() + if user: + return user + + try: + user = User( + username=username, + first_name=arabic_name, + last_name="", + email=f"{username}@alhammadi.med.sa", + is_active=True, + ) + user.save() + return user + except Exception: + return None + + def _build_title(self, row_data: Dict) -> str: + desc = row_data.get("description_en") or row_data.get("description_ar") or "" + return desc[:500] if desc else "No description" + + def _build_description(self, row_data: Dict) -> str: + desc_en = row_data.get("description_en") or "" + desc_ar = row_data.get("description_ar") or "" + + if desc_en and desc_ar: + return f"{desc_en}\n\n[Arabic]:\n{desc_ar}" + return desc_en or desc_ar or "No description provided" + + def _print_report(self): + self.stdout.write("\n" + "=" * 60) + self.stdout.write("IMPORT REPORT - 2025 BASIC") + self.stdout.write("=" * 60) + self.stdout.write(f"Sheet: {self.sheet_name}") + self.stdout.write(f"Mode: {'DRY RUN' if self.dry_run else 'ACTUAL'}") + self.stdout.write(f"\nProcessed: {self.stats['processed']}") + self.stdout.write(f"Success: {self.stats['success']}") + self.stdout.write(f"Failed: {self.stats['failed']}") + + if self.errors: + self.stdout.write(f"\nErrors: {len(self.errors)}") + for error in self.errors[:5]: + self.stdout.write(f" Row {error['row']}: {error['error']}") diff --git a/apps/complaints/management/commands/import_historical_complaints.py b/apps/complaints/management/commands/import_historical_complaints.py new file mode 100644 index 0000000..c1235a3 --- /dev/null +++ b/apps/complaints/management/commands/import_historical_complaints.py @@ -0,0 +1,614 @@ +""" +Import historical complaints from Excel (Aug-Dec 2022). + +Usage: + # Test import (AUG 2022 only, dry run) + python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="AUG 2022 " --dry-run + + # Actual import (AUG 2022) + python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="AUG 2022 " + + # Import all months + python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="SEP 2022 " + python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="OCT 2022" + python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="NOV 2022" + python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="DEC 2022" +""" + +import logging +import re +from datetime import datetime +from typing import Dict, List, Optional, Tuple + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from apps.organizations.models import Hospital, Location, MainSection, SubSection, Staff +from apps.complaints.models import Complaint, ComplaintCategory +from apps.accounts.models import User + +from .complaint_taxonomy_mapping import ( + DOMAIN_MAPPING, + CATEGORY_MAPPING, + SUBCATEGORY_MAPPING, + CLASSIFICATION_MAPPING, + get_mapped_category, + is_taxonomy_mapped, +) + +logger = logging.getLogger(__name__) + +# Default hospital code for all imported complaints +DEFAULT_HOSPITAL_CODE = "NUZHA-DEV" + +# Column mapping: field_name -> column_number (1-based) +COLUMN_MAPPING = { + "complaint_num": 3, # رقم الشكوى + "mrn": 4, # رقم الملف + "source": 5, # جهة الشكوى + "location_name": 6, # الموقع + "main_dept_name": 7, # القسم الرئيس + "sub_dept_name": 8, # القسم الفرعي + "date_received": 9, # تاريخ إستلام الشكوى + "data_entry_person": 10, # المدخل (Data Entry Person) + "accused_staff_id": 48, # ID (Employee ID) + "accused_staff_name": 49, # اسم الشخص المشتكى عليه - ان وجد + "domain": 50, # Domain + "category": 51, # Category + "subcategory": 52, # Sub-Category + "classification": 53, # Classification + "description_ar": 54, # محتوى الشكوى (عربي) + "description_en": 55, # محتوى الشكوى (English) + "satisfaction": 56, # Satisfied/Dissatisfied + "rightful_side": 57, # The Rightful Side + # Timeline columns + "date_sent": 20, # تم ارسال الشكوى (Complaint Sent/Activated) + "first_reminder": 24, # First Reminder Sent + "second_reminder": 28, # Second Reminder Sent + "escalated_date": 32, # Escalated + "closed_date": 37, # Closed + "resolved_date": 44, # Resolved + "response_date": 41, # تاريخ الرد (Response Date - for explanation received) +} + +# Month mapping for reference numbers +MONTH_MAP = { + # Full month names (2023-2025 format) + "JANUARY": "01", + "FEBRUARY": "02", + "MARCH": "03", + "APRIL": "04", + "MAY": "05", + "JUNE": "06", + "JULY": "07", + "AUGUST": "08", + "SEPTEMBER": "09", + "OCTOBER": "10", + "NOVEMBER": "11", + "DECEMBER": "12", + # Short names (2022 format for backward compatibility) + "AUG": "08", + "SEP": "09", + "OCT": "10", + "NOV": "11", + "DEC": "12", +} + + +class Command(BaseCommand): + help = "Import historical complaints from Excel (Aug-Dec 2022)" + + def add_arguments(self, parser): + parser.add_argument("excel_file", type=str, help="Path to the Excel file") + parser.add_argument( + "--sheet", type=str, default="AUG 2022 ", help='Sheet name to import (default: "AUG 2022 ")' + ) + parser.add_argument("--dry-run", action="store_true", help="Preview without saving to database") + parser.add_argument("--start-row", type=int, default=3, help="First data row (default: 3, skipping header)") + + def handle(self, *args, **options): + self.excel_file = options["excel_file"] + self.sheet_name = options["sheet"] + self.dry_run = options["dry_run"] + self.start_row = options["start_row"] + + # Load hospital + self.hospital = self._load_hospital() + if not self.hospital: + raise CommandError(f'Hospital with code "{DEFAULT_HOSPITAL_CODE}" not found') + + self.stdout.write(self.style.SUCCESS(f"Using hospital: {self.hospital.name}")) + + # Load Excel workbook + try: + import openpyxl + + self.wb = openpyxl.load_workbook(self.excel_file) + except ImportError: + raise CommandError("openpyxl is required. Install with: pip install openpyxl") + except Exception as e: + raise CommandError(f"Error loading Excel file: {e}") + + # Check sheet exists + if self.sheet_name not in self.wb.sheetnames: + available = ", ".join(self.wb.sheetnames) + raise CommandError(f'Sheet "{self.sheet_name}" not found. Available: {available}') + + self.ws = self.wb[self.sheet_name] + self.stdout.write(f"Processing sheet: {self.sheet_name}") + self.stdout.write(f"Total rows: {self.ws.max_row}") + + # Statistics tracking + self.stats = { + "processed": 0, + "success": 0, + "failed": 0, + "skipped_duplicate": 0, + "skipped_unmapped_taxonomy": 0, + } + self.errors = [] + self.unmapped_taxonomy = set() + self.unmatched_locations = set() + self.unmatched_departments = set() + + # Process rows + self._process_sheet() + + # Generate report + self._print_report() + + def _load_hospital(self) -> Optional[Hospital]: + """Load default hospital by code.""" + try: + return Hospital.objects.get(code=DEFAULT_HOSPITAL_CODE) + except Hospital.DoesNotExist: + return None + + def _process_sheet(self): + """Process all rows in the sheet.""" + row_num = self.start_row + + while row_num <= self.ws.max_row: + try: + # Extract row data + row_data = self._extract_row_data(row_num) + + # Skip empty rows + if not row_data.get("complaint_num"): + row_num += 1 + continue + + self.stats["processed"] += 1 + + # Check for duplicate + ref_num = self._build_reference_number(row_data["complaint_num"]) + if Complaint.objects.filter(reference_number=ref_num).exists(): + self.stats["skipped_duplicate"] += 1 + row_num += 1 + continue + + # Resolve taxonomy - skip if unmapped + taxonomy = self._resolve_taxonomy( + row_data.get("domain"), + row_data.get("category"), + row_data.get("subcategory"), + row_data.get("classification"), + ) + + if not is_taxonomy_mapped( + row_data.get("domain"), + row_data.get("category"), + row_data.get("subcategory"), + row_data.get("classification"), + ): + self.stats["skipped_unmapped_taxonomy"] += 1 + self._log_unmapped_taxonomy(row_data) + row_num += 1 + continue + + # Resolve location and departments + location = self._resolve_location(row_data.get("location_name")) + main_section = self._resolve_section(row_data.get("main_dept_name")) + subsection = self._resolve_subsection(row_data.get("sub_dept_name")) + + # Determine status + status = self._determine_status(row_data) + + # Parse date_received for created_at + date_received = row_data.get("date_received") + created_at = timezone.now() # Default fallback + if date_received: + if isinstance(date_received, str): + try: + created_at = datetime.strptime(date_received, "%Y-%m-%d %H:%M:%S") + except ValueError: + try: + created_at = datetime.strptime(date_received, "%Y-%m-%d") + except ValueError: + pass + elif isinstance(date_received, datetime): + created_at = date_received + + # Get or create data entry person user + data_entry_person = row_data.get("data_entry_person") + assigned_to_user = self._get_or_create_data_entry_user(data_entry_person) + + # Parse timeline dates + date_sent = self._parse_datetime(row_data.get("date_sent")) + first_reminder = self._parse_datetime(row_data.get("first_reminder")) + second_reminder = self._parse_datetime(row_data.get("second_reminder")) + escalated_date = self._parse_datetime(row_data.get("escalated_date")) + closed_date = self._parse_datetime(row_data.get("closed_date")) + resolved_date = self._parse_datetime(row_data.get("resolved_date")) + response_date = self._parse_datetime(row_data.get("response_date")) + + # Determine explanation tracking + explanation_requested = bool(date_sent) + explanation_requested_at = date_sent + explanation_received_at = response_date + + # Resolve accused staff + accused_staff_id = row_data.get("accused_staff_id") + accused_staff = self._resolve_staff_by_id(accused_staff_id) + + # Map rightful side to resolution outcome + rightful_side = row_data.get("rightful_side", "").lower().strip() + resolution_outcome = "" + if rightful_side in ["patient", "hospital", "other"]: + resolution_outcome = rightful_side + + if not self.dry_run: + # Create complaint + with transaction.atomic(): + complaint = Complaint.objects.create( + reference_number=ref_num, + hospital=self.hospital, + location=location, + main_section=main_section, + subsection=subsection, + title=self._build_title(row_data), + description=self._build_description(row_data), + patient_name="Unknown", + national_id="", + relation_to_patient="patient", + staff=accused_staff, + staff_name=row_data.get("accused_staff_name") or "", + domain=taxonomy.get("domain"), + category=taxonomy.get("category"), + subcategory_obj=taxonomy.get("subcategory"), + classification_obj=taxonomy.get("classification"), + status=status, + assigned_to=assigned_to_user, + resolved_by=assigned_to_user, + resolution_outcome=resolution_outcome, + # Timeline fields + activated_at=date_sent, + reminder_sent_at=first_reminder, + second_reminder_sent_at=second_reminder, + escalated_at=escalated_date, + closed_at=closed_date, + resolved_at=resolved_date, + # Explanation tracking + explanation_requested=explanation_requested, + explanation_requested_at=explanation_requested_at, + explanation_received_at=explanation_received_at, + metadata=self._build_metadata(row_data, ref_num), + ) + + # Update created_at to historical date (can't set during create due to auto_now_add) + Complaint.objects.filter(pk=complaint.pk).update(created_at=created_at) + + self.stats["success"] += 1 + + except Exception as e: + self.stats["failed"] += 1 + self.errors.append( + { + "row": row_num, + "complaint_num": row_data.get("complaint_num") if "row_data" in locals() else None, + "error": str(e), + } + ) + logger.error(f"Error processing row {row_num}: {e}", exc_info=True) + + row_num += 1 + + def _extract_row_data(self, row_num: int) -> Dict: + """Extract data from Excel row.""" + data = {} + for field, col in COLUMN_MAPPING.items(): + cell_value = self.ws.cell(row_num, col).value + # Clean classification field (remove Excel artifacts like "AX5:BA5") + if field == "classification" and cell_value: + cell_value = re.sub(r"[A-Z]+\d+:[A-Z]+\d+", "", str(cell_value)).strip() + data[field] = cell_value + return data + + def _build_reference_number(self, complaint_num) -> str: + """Build reference number: CMP-YYYY-MM-NNNN.""" + # Parse year and month from sheet name (e.g., "January 2023 " -> year=2023, month=January) + sheet_parts = self.sheet_name.strip().split() + year = sheet_parts[-1] if len(sheet_parts) > 1 else "2022" + month_part = sheet_parts[0].upper() + month_code = MONTH_MAP.get(month_part, "00") + return f"CMP-{year}-{month_code}-{int(complaint_num):04d}" + + def _resolve_taxonomy(self, domain, category, subcategory, classification) -> Dict: + """Resolve taxonomy to ComplaintCategory objects.""" + return { + "domain": self._get_category_by_uuid(get_mapped_category(domain, DOMAIN_MAPPING)), + "category": self._get_category_by_uuid(get_mapped_category(category, CATEGORY_MAPPING)), + "subcategory": self._get_category_by_uuid(get_mapped_category(subcategory, SUBCATEGORY_MAPPING)), + "classification": self._get_category_by_uuid(get_mapped_category(classification, CLASSIFICATION_MAPPING)), + } + + def _get_category_by_uuid(self, uuid: str) -> Optional[ComplaintCategory]: + """Get ComplaintCategory by UUID.""" + if not uuid: + return None + try: + return ComplaintCategory.objects.get(id=uuid) + except ComplaintCategory.DoesNotExist: + return None + + def _parse_datetime(self, value) -> Optional[datetime]: + """Parse datetime from various formats.""" + if not value: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + except ValueError: + try: + return datetime.strptime(value, "%Y-%m-%d") + except ValueError: + return None + return None + + def _resolve_location(self, name_ar: str) -> Optional[Location]: + """Resolve location by Arabic name.""" + if not name_ar: + return None + location = Location.objects.filter(name_ar=name_ar).first() + if not location: + self.unmatched_locations.add(name_ar) + return location + + def _resolve_section(self, name_ar: str) -> Optional[MainSection]: + """Resolve main section/department by Arabic name.""" + if not name_ar: + return None + # Try Section model + section = MainSection.objects.filter(name_ar=name_ar).first() + if not section: + self.unmatched_departments.add(name_ar) + return section + + def _resolve_subsection(self, name_ar: str) -> Optional[SubSection]: + """Resolve subsection by Arabic name.""" + if not name_ar: + return None + return SubSection.objects.filter(name_ar=name_ar).first() + + def _resolve_staff_by_id(self, employee_id: str) -> Optional[Staff]: + """Resolve staff by employee ID.""" + if not employee_id: + return None + try: + return Staff.objects.get(employee_id=str(employee_id)) + except Staff.DoesNotExist: + return None + + def _get_or_create_data_entry_user(self, arabic_name: str) -> Optional[User]: + """ + Create or get PX-Coordinator user from Arabic data entry person name. + + Transliterates Arabic name to Latin username using first and last name only. + Stores full Arabic name in first_name field. + + Args: + arabic_name: Arabic name from Excel (e.g., "أحمد محمد عبدالله") + + Returns: + User object or None if name is empty + """ + if not arabic_name: + return None + + try: + from unidecode import unidecode + except ImportError: + logger.error("unidecode library not installed. Run: pip install unidecode") + return None + + # Split name and get first and last parts only + parts = arabic_name.split() + if len(parts) >= 2: + first_name = parts[0] + last_name = parts[-1] + else: + first_name = arabic_name + last_name = "coordinator" + + # Transliterate to Latin for username + username_first = unidecode(first_name).lower().strip() + username_last = unidecode(last_name).lower().strip() + + # Clean username (remove special chars, spaces) + username_first = re.sub(r"[^a-z0-9]", "", username_first) + username_last = re.sub(r"[^a-z0-9]", "", username_last) + + if not username_first: + username_first = "user" + if not username_last: + username_last = "coordinator" + + username = f"{username_first}.{username_last}" + + # Check if user already exists + user = User.objects.filter(username=username).first() + if user: + return user + + # Check for similar users (same first name part) + similar_user = User.objects.filter(username__startswith=username_first, first_name=arabic_name).first() + if similar_user: + return similar_user + + # Create new user + try: + # Generate unique email + email = f"{username}@alhammadi.med.sa" + user = User( + username=username, + first_name=arabic_name, # Full Arabic name + last_name="", + email=email, + is_active=True, + ) + user.save() + logger.info(f"Created new PX-Coordinator user: {username} ({arabic_name})") + return user + except Exception as e: + logger.error(f"Error creating user {username}: {e}") + # Try with numbered suffix if username exists + for i in range(2, 100): + try: + email = f"{username}{i}@alhammadi.med.sa" + user = User( + username=f"{username}{i}", + first_name=arabic_name, + last_name="", + email=email, + is_active=True, + ) + user.save() + logger.info(f"Created new PX-Coordinator user: {username}{i} ({arabic_name})") + return user + except Exception as e2: + logger.error(f"Error creating user {username}{i}: {e2}") + continue + return None + + def _determine_status(self, row_data: Dict) -> str: + """Determine complaint status from timeline dates.""" + if row_data.get("closed_date"): + return "closed" + elif row_data.get("resolved_date"): + return "resolved" + elif row_data.get("escalated_date"): + return "in_progress" + else: + return "open" + + def _build_title(self, row_data: Dict) -> str: + """Build complaint title from description.""" + desc = row_data.get("description_en") or row_data.get("description_ar") or "" + return desc[:500] if desc else "No description" + + def _build_description(self, row_data: Dict) -> str: + """Build complaint description (English preferred).""" + desc_en = row_data.get("description_en") or "" + desc_ar = row_data.get("description_ar") or "" + + if desc_en and desc_ar: + return f"{desc_en}\n\n[Arabic]:\n{desc_ar}" + return desc_en or desc_ar or "No description provided" + + def _build_metadata(self, row_data: Dict, ref_num: str) -> Dict: + """Build metadata dictionary.""" + return { + "import_source": "historical_excel_2022", + "imported_at": datetime.now().isoformat(), + "original_sheet": self.sheet_name, + "reference_number": ref_num, + "original_complaint_num": row_data.get("complaint_num"), + "mrn": row_data.get("mrn"), + "source": row_data.get("source"), + "satisfaction": row_data.get("satisfaction"), + "original_staff_name": row_data.get("accused_staff"), + "original_location": row_data.get("location_name"), + "original_departments": { + "main": row_data.get("main_dept_name"), + "sub": row_data.get("sub_dept_name"), + }, + "taxonomy": { + "domain": row_data.get("domain"), + "category": row_data.get("category"), + "subcategory": row_data.get("subcategory"), + "classification": row_data.get("classification"), + }, + "timeline": { + "received": str(row_data.get("date_received")) if row_data.get("date_received") else None, + "sent": str(row_data.get("date_sent")) if row_data.get("date_sent") else None, + "first_reminder": str(row_data.get("first_reminder")) if row_data.get("first_reminder") else None, + "escalated": str(row_data.get("escalated_date")) if row_data.get("escalated_date") else None, + "closed": str(row_data.get("closed_date")) if row_data.get("closed_date") else None, + "resolved": str(row_data.get("resolved_date")) if row_data.get("resolved_date") else None, + }, + } + + def _log_unmapped_taxonomy(self, row_data: Dict): + """Log unmapped taxonomy items.""" + items = [ + row_data.get("domain"), + row_data.get("category"), + row_data.get("subcategory"), + row_data.get("classification"), + ] + for item in items: + if item: + self.unmapped_taxonomy.add(item) + + def _print_report(self): + """Print import summary report.""" + self.stdout.write("\n" + "=" * 80) + self.stdout.write(self.style.SUCCESS("IMPORT REPORT")) + self.stdout.write("=" * 80) + + self.stdout.write(f"\nSheet: {self.sheet_name}") + self.stdout.write(f"Mode: {'DRY RUN' if self.dry_run else 'ACTUAL IMPORT'}") + + self.stdout.write("\n--- Statistics ---") + self.stdout.write(f"Total rows processed: {self.stats['processed']}") + self.stdout.write(self.style.SUCCESS(f"Successfully imported: {self.stats['success']}")) + self.stdout.write(self.style.WARNING(f"Skipped (duplicates): {self.stats['skipped_duplicate']}")) + self.stdout.write(self.style.WARNING(f"Skipped (unmapped taxonomy): {self.stats['skipped_unmapped_taxonomy']}")) + self.stdout.write(self.style.ERROR(f"Failed: {self.stats['failed']}")) + + if self.unmapped_taxonomy: + self.stdout.write("\n--- Unmapped Taxonomy Items ---") + self.stdout.write("Add these to complaint_taxonomy_mapping.py:") + for item in sorted(self.unmapped_taxonomy): + self.stdout.write(f" - {item}") + + if self.unmatched_locations: + self.stdout.write("\n--- Unmatched Locations ---") + self.stdout.write("No Location found with these name_ar values:") + for loc in sorted(self.unmatched_locations): + self.stdout.write(f" - {loc}") + + if self.unmatched_departments: + self.stdout.write("\n--- Unmatched Departments ---") + self.stdout.write("No MainSection/SubSection found with these name_ar values:") + for dept in sorted(self.unmatched_departments): + self.stdout.write(f" - {dept}") + + if self.errors: + self.stdout.write("\n--- Errors ---") + self.stdout.write(f"Total errors: {len(self.errors)}") + for error in self.errors[:10]: # Show first 10 + self.stdout.write( + self.style.ERROR(f"Row {error['row']} (Complaint #{error['complaint_num']}): {error['error']}") + ) + if len(self.errors) > 10: + self.stdout.write(f"... and {len(self.errors) - 10} more errors") + + self.stdout.write("\n" + "=" * 80) + + if self.dry_run: + self.stdout.write(self.style.WARNING("\nThis was a DRY RUN. No data was saved.")) + self.stdout.write("Run without --dry-run to perform actual import.") diff --git a/apps/complaints/management/commands/seed_sample_data.py b/apps/complaints/management/commands/seed_sample_data.py new file mode 100644 index 0000000..84f20a8 --- /dev/null +++ b/apps/complaints/management/commands/seed_sample_data.py @@ -0,0 +1,952 @@ +import random +import uuid +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from apps.accounts.models import User +from apps.complaints.models import Complaint, ComplaintCategory, ComplaintUpdate, Inquiry, InquiryUpdate +from apps.organizations.models import Hospital, Department, Staff +from apps.observations.models import Observation, ObservationCategory, ObservationNote +from apps.px_sources.models import PXSource + +ENGLISH_COMPLAINTS_STAFF = [ + { + "title": "Rude behavior from nurse during shift", + "description": "I was extremely disappointed by the rude behavior of the nurse {staff_name} during the night shift on {date}. She was dismissive and unprofessional when I asked for pain medication.", + "category": "staff_behavior", + "severity": "critical", + "priority": "urgent", + }, + { + "title": "Physician misdiagnosed my condition", + "description": "Dr. {staff_name} misdiagnosed my condition and prescribed wrong medication. I had to suffer for 3 more days before another doctor caught the error.", + "category": "clinical_care", + "severity": "critical", + "priority": "urgent", + }, + { + "title": "Nurse ignored call button for over 30 minutes", + "description": "Despite pressing the call button multiple times, nurse {staff_name} did not respond for over 30 minutes. When she finally arrived, she was annoyed and unhelpful.", + "category": "staff_behavior", + "severity": "high", + "priority": "high", + }, + { + "title": "Physician did not explain treatment plan clearly", + "description": "Dr. {staff_name} did not take the time to explain my diagnosis or treatment plan. He was rushing and seemed impatient with my questions.", + "category": "clinical_care", + "severity": "high", + "priority": "high", + }, + { + "title": "Nurse made medication error", + "description": "Nurse {staff_name} attempted to give me medication meant for another patient. I only noticed because the name on the label was different. This is a serious safety concern.", + "category": "clinical_care", + "severity": "critical", + "priority": "urgent", + }, + { + "title": "Admin staff was unhelpful with billing inquiry", + "description": "The administrative staff member {staff_name} was extremely unhelpful when I asked questions about my bill. She was dismissive and refused to explain the charges properly.", + "category": "communication", + "severity": "medium", + "priority": "medium", + }, +] + +ENGLISH_COMPLAINTS_GENERAL = [ + { + "title": "Long wait time in emergency room", + "description": "I had to wait over 4 hours in the emergency room despite being in severe pain. The lack of attention and delay in treatment was unacceptable.", + "category": "wait_time", + "severity": "high", + "priority": "high", + }, + { + "title": "Room was not clean upon admission", + "description": "When I was admitted to my room, it was not properly cleaned. There was dust on the surfaces and the bathroom was not sanitary.", + "category": "facility", + "severity": "medium", + "priority": "medium", + }, + { + "title": "Air conditioning not working properly", + "description": "The air conditioning in my room was not working for 2 days. Despite multiple complaints to staff, nothing was done.", + "category": "facility", + "severity": "medium", + "priority": "medium", + }, + { + "title": "Billing statement has incorrect charges", + "description": "My billing statement contains charges for procedures and medications I never received. I have tried to resolve this issue multiple times.", + "category": "billing", + "severity": "high", + "priority": "high", + }, + { + "title": "Difficulty getting prescription refills", + "description": "Getting prescription refills has been extremely difficult. The process is unclear and there is poor communication between the pharmacy and doctors.", + "category": "communication", + "severity": "medium", + "priority": "medium", + }, + { + "title": "Food quality has declined", + "description": "The quality of hospital food has significantly declined. Meals are often cold, not appetizing, and don't meet dietary requirements.", + "category": "facility", + "severity": "medium", + "priority": "medium", + }, + { + "title": "Poor discharge process and instructions", + "description": "The discharge process was chaotic. I was given contradictory instructions about my medication and follow-up care. Nobody explained the next steps clearly.", + "category": "communication", + "severity": "high", + "priority": "high", + }, +] + +ARABIC_COMPLAINTS_STAFF = [ + { + "title": "سلوك غير مهذب من الممرضة أثناء المناوبة", + "description": "كنت محبطاً جداً من السلوك غير المهذب للممرضة {staff_name} خلال المناوبة الليلية في {date}. كانت متجاهلة وغير مهنية عندما طلبت دواء للم.", + "category": "staff_behavior", + "severity": "critical", + "priority": "urgent", + }, + { + "title": "الطبيب تشخص خطأ في حالتي", + "description": "تشخص د. {staff_name} خطأ في حالتي ووصف دواء خاطئ. اضطررت للمعاناة لمدة 3 أيام إضافية قبل أن يكتشف طبيب آخر الخطأ.", + "category": "clinical_care", + "severity": "critical", + "priority": "urgent", + }, + { + "title": "الممرضة تجاهلت زر الاستدعاء لأكثر من 30 دقيقة", + "description": "على الرغم من الضغط على زر الاستدعاء عدة مرات، لم تستجب الممرضة {staff_name} لأكثر من 30 دقيقة.", + "category": "staff_behavior", + "severity": "high", + "priority": "high", + }, + { + "title": "الطبيب لم يوضح خطة العلاج بوضوح", + "description": "د. {staff_name} لم يأخذ الوقت لتوضيح تشخيصي أو خطة العلاج. كان يتسرع ويبدو متضايقاً من أسئلتي.", + "category": "clinical_care", + "severity": "high", + "priority": "high", + }, + { + "title": "الممرضة ارتكبت خطأ في الدواء", + "description": "حاولت الممرضة {staff_name} إعطائي دواء مخصص لمريض آخر. لاحظت ذلك فقط لأن الاسم على الملصق مختلف.", + "category": "clinical_care", + "severity": "critical", + "priority": "urgent", + }, + { + "title": "موظف الإدارة كان غير مفيد في استفسار الفوترة", + "description": "كان موظف الإدارة {staff_name} غير مفيد جداً عندما سألت عن فاتورتي. كان متجاهلاً ورفض توضيح الرسوم بشكل صحيح.", + "category": "communication", + "severity": "medium", + "priority": "medium", + }, +] + +ARABIC_COMPLAINTS_GENERAL = [ + { + "title": "وقت انتظار طويل في الطوارئ", + "description": "اضطررت للانتظار أكثر من 4 ساعات في غرفة الطوارئ رغم أنني كنت أعاني من ألم شديد. عدم الانتباه والتأخير في العلاج غير مقبول.", + "category": "wait_time", + "severity": "high", + "priority": "high", + }, + { + "title": "الغرفة لم تكن نظيفة عند القبول", + "description": "عندما تم قبولي في غرفتي، لم تكن نظيفة بشكل صحيح. كان هناك غبار على الأسطح وحمام غير صحي.", + "category": "facility", + "severity": "medium", + "priority": "medium", + }, + { + "title": "التكييف لا يعمل بشكل صحيح", + "description": "لم يكن التكييف في غرفتي يعمل لمدة يومين. على الرغم من شكاوى متعددة للموظفين، لم يتم فعل شيء.", + "category": "facility", + "severity": "medium", + "priority": "medium", + }, + { + "title": "كشف الفاتورة يحتوي على رسوم غير صحيحة", + "description": "كشف فاتورتي يحتوي على رسوم لإجراءات وأدوية لم أتلقها أبداً. حاولت حل هذه المشكلة عدة مرات لكن لم أتلق أي مساعدة.", + "category": "billing", + "severity": "high", + "priority": "high", + }, + { + "title": "صعوبة الحصول على وصفات طبية", + "description": "الحصول على وصفات طبية كان صعباً للغاية. العملية غير واضحة وهناك تواصل سيء بين الصيدلية والأطباء.", + "category": "communication", + "severity": "medium", + "priority": "medium", + }, + { + "title": "جودة الطعام انخفضت", + "description": "جودة طعام المستشفى انخفضت بشكل كبير. الوجبات غالباً باردة وغير شهية ولا تلبي المتطلبات الغذائية.", + "category": "facility", + "severity": "medium", + "priority": "medium", + }, + { + "title": "عملية الخروج فوضوية وغير واضحة", + "description": "كانت عملية الخروج فوضوية. أعطيت تعليمات متناقضة حول الدواء والمتابعة. لم يشرح أحد الخطوات التالية بوضوح.", + "category": "communication", + "severity": "high", + "priority": "high", + }, +] + +PATIENT_NAMES_EN = [ + "John Smith", + "Sarah Johnson", + "Ahmed Al-Rashid", + "Fatima Hassan", + "Michael Brown", + "Layla Al-Otaibi", + "David Wilson", + "Nora Al-Dosari", + "James Taylor", + "Aisha Al-Qahtani", +] +PATIENT_NAMES_AR = [ + "محمد العتيبي", + "فاطمة الدوسري", + "أحمد القحطاني", + "سارة الشمري", + "خالد الحربي", + "نورة المطيري", + "عبدالله العنزي", + "مريم الزهراني", + "سعود الشهري", + "هند السالم", +] + +INQUIRY_TITLES = [ + {"en": "Question about appointment booking", "ar": "سؤال عن حجز موعد", "category": "appointment"}, + {"en": "Inquiry about insurance coverage", "ar": "استفسار عن التغطية التأمينية", "category": "billing"}, + {"en": "Request for medical records", "ar": "طلب ملف طبي", "category": "medical_records"}, + {"en": "Information about hospital services", "ar": "معلومات عن خدمات المستشفى", "category": "general"}, + {"en": "Question about doctor availability", "ar": "سؤال عن توفر الأطباء", "category": "appointment"}, + {"en": "Inquiry about test results", "ar": "استفسار عن نتائج الفحوصات", "category": "medical_records"}, + {"en": "Request for price list", "ar": "طلب قائمة الأسعار", "category": "billing"}, + {"en": "Question about visiting hours", "ar": "سؤال عن مواعيد الزيارة", "category": "general"}, + {"en": "Inquiry about specialized treatment", "ar": "استفسار عن علاج تخصصي", "category": "general"}, + {"en": "Request for second opinion", "ar": "طلب رأي ثاني", "category": "general"}, + {"en": "Question about discharge process", "ar": "سؤال عن عملية الخروج", "category": "general"}, + { + "en": "Inquiry about medication side effects", + "ar": "استفسار عن الآثار الجانبية للأدوية", + "category": "medical_records", + }, + {"en": "Request for dietary information", "ar": "طلب معلومات غذائية", "category": "general"}, + {"en": "Question about transportation", "ar": "سؤال عن وسائل النقل", "category": "general"}, + {"en": "Inquiry about follow-up appointments", "ar": "استفسار عن مواعيد المتابعة", "category": "appointment"}, + {"en": "Inquiry about patient rights", "ar": "استفسار عن حقوق المرضى", "category": "general"}, + {"en": "Question about hospital policies", "ar": "سؤال عن سياسات المستشفى", "category": "general"}, + { + "en": "Inquiry about international patient services", + "ar": "استفسار عن خدمات المرضى الدوليين", + "category": "general", + }, +] + +OBSERVATION_TEMPLATES = [ + { + "title_en": "Hand hygiene not followed before patient contact", + "title_ar": "عدم الالتزام بالنظافة اليدوية قبل ملامسة المريض", + "severity": "high", + "description_en": "Observed healthcare worker entering patient room without performing hand hygiene.", + "description_ar": "لوح عامل رعاية صحية يدخل غرفة المريض دون أداء النظافة اليدوية.", + }, + { + "title_en": "PPE not properly worn in isolation area", + "title_ar": "عدم ارتداء معدات الوقاية بشكل صحيح في منطقة العزل", + "severity": "critical", + "description_en": "Staff in isolation area not wearing proper PPE including masks and gowns.", + "description_ar": "الموظفون في منطقة العزل لا يرتدون معدات الوقاية المناسبة بما في ذلك الأقنعة والأرواب.", + }, + { + "title_en": "Medication storage at incorrect temperature", + "title_ar": "تخزين الأدوية في درجة حرارة غير صحيحة", + "severity": "high", + "description_en": "Found medication refrigerator not maintaining required temperature range.", + "description_ar": "ثبت أن ثلاجة الأدوية لا تحافظ على نطاق درجة الحرارة المطلوب.", + }, + { + "title_en": "Patient identification bands missing", + "title_ar": "عدم وجود أساور تعريف المرضى", + "severity": "critical", + "description_en": "Multiple patients in ward found without proper identification bands.", + "description_ar": "وجد عدة مرضى في الجناح بدون أساور تعريف مناسبة.", + }, + { + "title_en": "Expired supplies found in treatment room", + "title_ar": "العثور على مستلزمات منتهية الصلاحية في غرفة العلاج", + "severity": "medium", + "description_en": "Several medical supplies past their expiration date found during routine check.", + "description_ar": "العثور على عدة مستلزمات طبية منتهية الصلاحية أثناء الفحص الروتيني.", + }, + { + "title_en": "Fall risk assessment not completed", + "title_ar": "لم يتم إكمال تقييم خطر السقوط", + "severity": "high", + "description_en": "Elderly patient admitted without documented fall risk assessment.", + "description_ar": "تم قبول مريض مسن بدون تقييم موثق لخطر السقوط.", + }, + { + "title_en": "Cleanliness issue in outpatient waiting area", + "title_ar": "مشكلة نظافة في منطقة انتظار العيادات الخارجية", + "severity": "low", + "description_en": "Waiting area floor and seating not properly cleaned between patient visits.", + "description_ar": "أرضية ومنطقة الجلوس في منطقة الانتظار لم يتم تنظيفها بشكل صحيح بين زيارات المرضى.", + }, + { + "title_en": "Incorrect waste segregation observed", + "title_ar": "لوح سوء فرز النفايات", + "severity": "medium", + "description_en": "Medical and general waste being disposed in the same containers.", + "description_ar": "التخلص من النفايات الطبية والعامة في نفس الحاويات.", + }, + { + "title_en": "Emergency exit blocked by equipment", + "title_ar": "مخرج الطوارئ مسدود بالمعدات", + "severity": "critical", + "description_en": "Emergency exit in corridor blocked by stored medical equipment.", + "description_ar": "مخرج الطوارئ في الممر مسدود بالمعدات الطبية المخزنة.", + }, + { + "title_en": "Improper IV line labeling", + "title_ar": "وضع ملصقات غير صحيحة على خطوط الوريد", + "severity": "high", + "description_en": "IV lines not labeled with installation date and time as required.", + "description_ar": "خطوط الوريد غير مُعلَّمة بتاريخ ووقت التركيب كما هو مطلوب.", + }, + { + "title_en": "Fire extinguisher expired in nursing station", + "title_ar": "طفاية حريق منتهية الصلاحية في محطة التمريض", + "severity": "medium", + "description_en": "Fire extinguisher past inspection date found at main nursing station.", + "description_ar": "طفاية حريق تجاوزت تاريخ الفحص في محطة التمريض الرئيسية.", + }, + { + "title_en": "Sharps container overfilled", + "title_ar": "حاوية الأدوات الحادة ممتلئة", + "severity": "high", + "description_en": "Sharps container in procedure room filled beyond the safe fill line.", + "description_ar": "حاوية الأدوات الحادة في غرفة الإجراءات ممتلئة فوق خط الملء الآمن.", + }, + { + "title_en": "Patient left unattended in corridor", + "title_ar": "مريض متروك دون مراقبة في الممر", + "severity": "medium", + "description_en": "Patient on wheelchair found alone in corridor for extended period without staff nearby.", + "description_ar": "وجد مريض على كرسي متحرك بمفرده في الممر لفترة طويلة بدون موظفين في الجوار.", + }, + { + "title_en": "Infection control sign missing from isolation room", + "title_ar": "لافتحة مكافحة العدوى مفقودة من غرفة العزل", + "severity": "medium", + "description_en": "Required infection control signage not displayed at isolation room entrance.", + "description_ar": "لافتحات مكافحة العدوى المطلوبة غير معروضة عند مدخل غرفة العزل.", + }, + { + "title_en": "Oxygen supply equipment not regularly checked", + "title_ar": "معدات إمداد الأكسجين لا يتم فحصها بانتظام", + "severity": "high", + "description_en": "Documentation shows oxygen supply equipment has not been inspected per schedule.", + "description_ar": "الوثائق تظهر أن معدات إمداد الأكسجين لم يتم فحصها وفق الجدول.", + }, +] + +RESOLUTION_TEXTS = [ + "The issue has been investigated and resolved. Corrective actions have been implemented and staff have been briefed on proper procedures.", + "Patient was contacted and offered a sincere apology. Compensation was provided and process improvements were made to prevent recurrence.", + "The complaint was investigated by the department manager. The staff member received additional training and the issue has been fully resolved.", + "After thorough review, the matter has been addressed. New protocols have been established to ensure this does not happen again.", + "The issue was resolved after coordination between departments. An action plan has been implemented and monitoring is in place.", + "Staff counseling was provided and the workflow has been updated. Patient expressed satisfaction with the resolution.", + "Management reviewed the case and implemented systemic changes. All involved staff were briefed and monitoring continues.", +] + + +class Command(BaseCommand): + help = "Seed sample complaints, observations, and inquiries with varied statuses and dates" + + def add_arguments(self, parser): + parser.add_argument("--complaints", type=int, default=30, help="Number of complaints (default: 30)") + parser.add_argument("--observations", type=int, default=20, help="Number of observations (default: 20)") + parser.add_argument("--inquiries", type=int, default=15, help="Number of inquiries (default: 15)") + parser.add_argument("--hospital-code", type=str, help="Specific hospital code") + parser.add_argument("--months-back", type=int, default=6, help="How far back to spread dates (default: 6)") + parser.add_argument("--dry-run", action="store_true", help="Preview without creating") + parser.add_argument("--clear", action="store_true", help="Delete existing sample data first") + + def handle(self, *args, **options): + self.dry_run = options["dry_run"] + self.months_back = options["months_back"] + self.cutoff_date = timezone.now() - timedelta(days=self.months_back * 30) + + self.stdout.write(f"\n{'=' * 60}") + self.stdout.write("Sample Data Seeding Command") + self.stdout.write(f"{'=' * 60}\n") + + hospitals = self._get_hospitals(options["hospital_code"]) + if not hospitals: + return + + px_coordinators = User.objects.filter(groups__name="PX Coordinator", is_active=True) + if not px_coordinators.exists(): + self.stdout.write( + self.style.WARNING("No PX Coordinator users found. Unassigned items will have no assignee.") + ) + px_coordinators = User.objects.filter(groups__name="Hospital Admin", is_active=True) + + all_staff = Staff.objects.filter(status="active") + complaint_categories = ComplaintCategory.objects.filter(is_active=True) + obs_categories = ObservationCategory.objects.filter(is_active=True) + sources = PXSource.objects.filter(is_active=True) + + if not sources.exists(): + self._ensure_pxsources() + sources = PXSource.objects.filter(is_active=True) + + if not obs_categories.exists(): + self.stdout.write( + self.style.WARNING( + "No observation categories found. Run setup_dev_environment or seed_observation_categories first." + ) + ) + return + + if self.dry_run: + self.stdout.write(self.style.WARNING("DRY RUN MODE\n")) + + self._print_config(options) + + if options["clear"]: + self._clear_sample_data() + + complaints_created = self._seed_complaints( + options["complaints"], hospitals, px_coordinators, all_staff, complaint_categories, sources + ) + observations_created = self._seed_observations( + options["observations"], hospitals, px_coordinators, obs_categories + ) + inquiries_created = self._seed_inquiries(options["inquiries"], hospitals, px_coordinators, sources) + + self._print_summary(complaints_created, observations_created, inquiries_created) + + def _get_hospitals(self, hospital_code): + if hospital_code: + hospitals = Hospital.objects.filter(code=hospital_code) + if not hospitals.exists(): + self.stdout.write(self.style.ERROR(f"Hospital with code '{hospital_code}' not found")) + return [] + else: + hospitals = Hospital.objects.filter(status="active") + if not hospitals.exists(): + self.stdout.write(self.style.ERROR("No active hospitals found.")) + return [] + self.stdout.write(self.style.SUCCESS(f"Hospitals: {hospitals.count()}")) + return list(hospitals) + + def _ensure_pxsources(self): + for key, (name_en, name_ar) in PX_SOURCE_MAP.items(): + PXSource.objects.get_or_create(name_en=name_en, defaults={"name_ar": name_ar, "is_active": True}) + + def _print_config(self, options): + self.stdout.write("Configuration:") + self.stdout.write(f" Complaints: {options['complaints']}") + self.stdout.write(f" Observations: {options['observations']}") + self.stdout.write(f" Inquiries: {options['inquiries']}") + self.stdout.write(f" Date spread: Last {options['months_back']} months") + self.stdout.write(f" Dry run: {self.dry_run}") + self.stdout.write("") + + def _clear_sample_data(self): + if self.dry_run: + self.stdout.write(self.style.WARNING("Would clear sample data (dry run)")) + return + c = Complaint.objects.filter(reference_number__startswith="SEED-").count() + o = Observation.objects.filter(tracking_code__startswith="SEED-").count() + i = Inquiry.objects.filter(subject__startswith="[SEED]").count() + Complaint.objects.filter(reference_number__startswith="SEED-").delete() + Observation.objects.filter(tracking_code__startswith="SEED-").delete() + Inquiry.objects.filter(subject__startswith="[SEED]").delete() + self.stdout.write(self.style.SUCCESS(f"Cleared: {c} complaints, {o} observations, {i} inquiries")) + + def _random_date(self, max_days_ago=None, min_days_ago=None): + if max_days_ago is None: + max_days_ago = self.months_back * 30 + if min_days_ago is None: + min_days_ago = 0 + days_ago = random.randint(min_days_ago, max(min_days_ago, self.months_back * 30)) + return timezone.now() - timedelta(days=days_ago) + + def _seed_complaints(self, count, hospitals, px_coordinators, all_staff, categories, sources): + self.stdout.write("\n--- Complaints ---") + + complaint_statuses = [ + ("open", 0.15), + ("in_progress", 0.20), + ("contacted", 0.10), + ("partially_resolved", 0.08), + ("resolved", 0.17), + ("closed", 0.20), + ("contacted_no_response", 0.05), + ("cancelled", 0.05), + ] + + statuses, weights = zip(*complaint_statuses) + created = [] + + for i in range(count): + hospital = random.choice(hospitals) + status = random.choices(statuses, weights=weights, k=1)[0] + is_arabic = random.random() < 0.70 + + if random.random() < 0.6 and all_staff.exists(): + hospital_staff = all_staff.filter(hospital=hospital) + staff_member = random.choice(hospital_staff) if hospital_staff.exists() else random.choice(all_staff) + templates = ARABIC_COMPLAINTS_STAFF if is_arabic else ENGLISH_COMPLAINTS_STAFF + else: + staff_member = None + templates = ARABIC_COMPLAINTS_GENERAL if is_arabic else ENGLISH_COMPLAINTS_GENERAL + + template = random.choice(templates) + description = template["description"] + if staff_member: + name = ( + f"{staff_member.first_name_ar} {staff_member.last_name_ar}" + if is_arabic + else f"{staff_member.first_name} {staff_member.last_name}" + ) + description = description.format(staff_name=name, date=self._random_date(60).strftime("%Y-%m-%d")) + + days_ago = self._days_for_status(status)[0] + created_at = self._random_date(days_ago, days_ago // 2) + + category = random.choice(categories) if categories.exists() else None + source = random.choice(sources) if sources.exists() else None + dept = staff_member.department if staff_member else None + + ref = f"SEED-{hospital.code}-{str(uuid.uuid4())[:8].upper()}" + patient_names = PATIENT_NAMES_AR if is_arabic else PATIENT_NAMES_EN + contact_name = random.choice(patient_names) + + if self.dry_run: + self.stdout.write(f" Would create: [{status}] {template['title'][:60]}") + created.append({"status": status, "lang": "ar" if is_arabic else "en"}) + continue + + complaint = Complaint( + reference_number=ref, + hospital=hospital, + department=dept, + category=category, + title=template["title"], + description=description, + severity=template["severity"], + priority=template["priority"], + source=source, + status=status, + contact_name=contact_name, + contact_phone=f"+9665{random.randint(10000000, 99999999)}", + created_at=created_at, + updated_at=created_at, + ) + if staff_member: + complaint.staff = staff_member + if status not in ("open",): + if status in ("open",): + coordinator = random.choice(px_coordinators) if px_coordinators.exists() else None + complaint.assigned_to = coordinator + complaint.assigned_at = created_at + timedelta(minutes=random.randint(5, 60)) + else: + complaint.assigned_to = random.choice(px_coordinators) if px_coordinators.exists() else None + complaint.assigned_at = created_at + timedelta(minutes=random.randint(5, 60)) + complaint.activated_at = created_at + timedelta(minutes=random.randint(30, 120)) + + if status in ("resolved", "closed"): + resolved_days = max(1, days_ago // 2) + complaint.resolved_at = created_at + timedelta(days=resolved_days) + complaint.resolved_by = complaint.assigned_to + complaint.resolution = random.choice(RESOLUTION_TEXTS) + + if status == "closed": + closed_days = max(1, days_ago // 3) + complaint.closed_at = created_at + timedelta(days=closed_days) + complaint.closed_by = complaint.assigned_to + + complaint.save() + + self._create_complaint_timeline(complaint, created_at, days_ago) + created.append(complaint) + + self.stdout.write(self.style.SUCCESS(f"Created {len(created)} complaints")) + self._print_status_breakdown([c.status for c in created]) + return created + + def _days_for_status(self, status): + mapping = { + "open": (0, 7), + "in_progress": (1, 30), + "contacted": (2, 20), + "partially_resolved": (10, 40), + "resolved": (15, 60), + "closed": (30, 180), + "contacted_no_response": (10, 45), + "cancelled": (20, 90), + } + return mapping.get(status, (1, 30)) + + def _create_complaint_timeline(self, complaint, created_at, days_ago): + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message="Complaint received and registered", + created_by=None, + created_at=created_at, + ) + + if complaint.status in ( + "in_progress", + "contacted", + "partially_resolved", + "resolved", + "closed", + "contacted_no_response", + ): + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="status_change", + message=f"Complaint activated and assigned to {complaint.assigned_to.get_full_name() if complaint.assigned_to else 'PX Coordinator'}", + old_status="open", + new_status="in_progress", + created_by=complaint.assigned_to, + created_at=complaint.assigned_at if complaint.assigned_at else created_at + timedelta(minutes=5), + ) + + if complaint.status in ("resolved", "closed"): + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="status_change", + message="Complaint resolved after investigation. Corrective actions taken.", + old_status="in_progress", + new_status="resolved", + created_by=complaint.resolved_by, + created_at=complaint.resolved_at if complaint.resolved_at else created_at, + ) + + if complaint.status == "closed": + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="status_change", + message="Complaint closed after verification.", + old_status="resolved", + new_status="closed", + created_by=complaint.closed_by, + created_at=complaint.closed_at if complaint.closed_at else created_at, + ) + + if complaint.status == "contacted_no_response": + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message="Staff member has not responded to explanation request. Follow-up required.", + created_by=complaint.assigned_to, + created_at=created_at + timedelta(days=max(1, days_ago // 2)), + ) + + def _seed_observations(self, count, hospitals, px_coordinators, obs_categories): + self.stdout.write("\n--- Observations ---") + + obs_statuses = [ + ("new", 0.15), + ("triaged", 0.10), + ("assigned", 0.10), + ("in_progress", 0.15), + ("contacted", 0.05), + ("contacted_no_response", 0.02), + ("resolved", 0.15), + ("closed", 0.20), + ("rejected", 0.05), + ("duplicate", 0.03), + ] + + statuses, weights = zip(*obs_statuses) + created = [] + + for i in range(count): + hospital = random.choice(hospitals) + status = random.choices(statuses, weights=weights, k=1)[0] + template = random.choice(OBSERVATION_TEMPLATES) + is_arabic = random.random() < 0.70 + + days_ago = self._obs_days_for_status(status)[0] + created_at = self._random_date(days_ago, days_ago // 2) + + tracking_code = f"SEED-{hospital.code}-{str(uuid.uuid4())[:6].upper()}" + category = random.choice(obs_categories) if obs_categories.exists() else None + + title = template["title_ar"] if is_arabic else template["title_en"] + description = template["description_ar"] if is_arabic else template["description_en"] + + if self.dry_run: + self.stdout.write(f" Would create: [{status}] {title[:60]}") + created.append({"status": status}) + continue + + obs = Observation( + hospital=hospital, + tracking_code=tracking_code, + title=title, + description=description, + severity=template["severity"], + category=category, + status=status, + source="staff_portal", + incident_datetime=created_at, + reporter_name=random.choice(PATIENT_NAMES_AR if is_arabic else PATIENT_NAMES_EN), + created_at=created_at, + updated_at=created_at, + ) + + if status not in ("new",): + obs.assigned_to = random.choice(px_coordinators) if px_coordinators.exists() else None + + if status in ("resolved", "closed"): + obs.resolved_at = created_at + timedelta(days=max(1, days_ago // 2)) + obs.resolved_by = obs.assigned_to + obs.resolution_notes = random.choice(RESOLUTION_TEXTS) + + if status == "closed": + obs.closed_at = created_at + timedelta(days=max(1, days_ago // 3)) + obs.closed_by = obs.assigned_to + + obs.save() + + self._create_observation_notes(obs, created_at, days_ago, is_arabic) + created.append(obs) + + self.stdout.write(self.style.SUCCESS(f"Created {len(created)} observations")) + self._print_status_breakdown([o.status for o in created]) + return created + + def _obs_days_for_status(self, status): + mapping = { + "new": (0, 7), + "triaged": (3, 14), + "assigned": (5, 20), + "in_progress": (10, 30), + "contacted": (5, 15), + "contacted_no_response": (10, 30), + "resolved": (15, 60), + "closed": (30, 180), + "rejected": (10, 45), + "duplicate": (20, 90), + } + return mapping.get(status, (1, 30)) + + def _create_observation_notes(self, obs, created_at, days_ago, is_arabic): + if is_arabic: + notes = { + "new": "ملاحظة جديدة مسجلة وبانتظار المراجعة.", + "triaged": "تم تصنيف الملاحظة وتحديد أولوية المعالجة.", + "in_progress": "الملاحظة قيد التحقيق حالياً.", + "resolved": "تم حل الملاحظة واتخاذ الإجراءات التصحيحية.", + "closed": "تم إغلاق الملاحظة بعد التحقق.", + "rejected": "تم رفض الملاحظة بعد المراجعة.", + "duplicate": "تم تحديد هذه الملاحظة كنسخة مكررة.", + "contacted": "تم التواصل مع القسم المعني لمتابعة الملاحظة.", + "contacted_no_response": "لم يتم الرد من القسم المعني. مطلوب متابعة.", + "assigned": "تم تعيين الملاحظة لمسؤول للمعالجة.", + } + else: + notes = { + "new": "New observation registered and pending review.", + "triaged": "Observation triaged and priority level assigned.", + "in_progress": "Observation is currently under investigation.", + "resolved": "Observation resolved with corrective actions taken.", + "closed": "Observation closed after verification.", + "rejected": "Observation rejected after review.", + "duplicate": "Observation marked as duplicate of an existing one.", + "contacted": "Department contacted for follow-up on observation.", + "contacted_no_response": "No response from department. Follow-up required.", + "assigned": "Observation assigned for investigation and resolution.", + } + + ObservationNote.objects.create( + observation=obs, + note=notes.get(obs.status, "Observation created."), + created_by=obs.assigned_to, + created_at=created_at, + ) + + if obs.status in ("resolved", "closed"): + ObservationNote.objects.create( + observation=obs, + note=random.choice(RESOLUTION_TEXTS)[:200], + created_by=obs.resolved_by, + created_at=obs.resolved_at if obs.resolved_at else created_at, + ) + + def _seed_inquiries(self, count, hospitals, px_coordinators, sources): + self.stdout.write("\n--- Inquiries ---") + + inquiry_statuses = [ + ("open", 0.15), + ("in_progress", 0.20), + ("contacted", 0.10), + ("contacted_no_response", 0.05), + ("resolved", 0.25), + ("closed", 0.25), + ] + + statuses, weights = zip(*inquiry_statuses) + created = [] + + for i in range(count): + hospital = random.choice(hospitals) + status = random.choices(statuses, weights=weights, k=1)[0] + is_arabic = random.random() < 0.70 + template = random.choice(INQUIRY_TITLES) + + days_ago = self._inquiry_days_for_status(status)[0] + created_at = self._random_date(days_ago, days_ago // 2) + + subject = template["ar"] if is_arabic else f"[SEED] {template['en']}" + category = template["category"] + message = f"This is a {category} inquiry regarding {template['en'].lower()}. The patient is requesting information and assistance with their healthcare needs at the hospital." + if is_arabic: + message = f"هذا استفسار {template['ar']} يتعلق بطلب معلومات ومساعدة في الاحتياجات الصحية." + + source = random.choice(sources) if sources.exists() else None + + if self.dry_run: + self.stdout.write(f" Would create: [{status}] {template['en'][:60]}") + created.append({"status": status}) + continue + + inquiry = Inquiry( + hospital=hospital, + subject=subject, + message=message, + category=category, + status=status, + source=source, + contact_name=random.choice(PATIENT_NAMES_AR if is_arabic else PATIENT_NAMES_EN), + contact_phone=f"+9665{random.randint(10000000, 99999999)}", + created_at=created_at, + updated_at=created_at, + ) + + if status not in ("open",): + inquiry.assigned_to = random.choice(px_coordinators) if px_coordinators.exists() else None + inquiry.assigned_at = created_at + timedelta(minutes=random.randint(5, 60)) + + if status in ("resolved", "closed"): + inquiry.response = random.choice(RESOLUTION_TEXTS) + + if status == "closed": + pass + + inquiry.save() + + self._create_inquiry_updates(inquiry, created_at, days_ago) + created.append(inquiry) + + self.stdout.write(self.style.SUCCESS(f"Created {len(created)} inquiries")) + self._print_status_breakdown([i.status for i in created]) + return created + + def _inquiry_days_for_status(self, status): + mapping = { + "open": (0, 7), + "in_progress": (1, 30), + "contacted": (2, 20), + "contacted_no_response": (10, 30), + "resolved": (15, 90), + "closed": (30, 180), + } + return mapping.get(status, (1, 30)) + + def _create_inquiry_updates(self, inquiry, created_at, days_ago): + InquiryUpdate.objects.create( + inquiry=inquiry, + update_type="note", + message="Inquiry received and registered", + created_by=None, + created_at=created_at, + ) + + if inquiry.status in ("in_progress", "contacted", "contacted_no_response", "resolved", "closed"): + InquiryUpdate.objects.create( + inquiry=inquiry, + update_type="note", + message=f"Inquiry assigned to {inquiry.assigned_to.get_full_name() if inquiry.assigned_to else 'PX Coordinator'} for handling.", + created_by=inquiry.assigned_to, + created_at=inquiry.assigned_at if inquiry.assigned_at else created_at + timedelta(minutes=5), + ) + + if inquiry.status in ("resolved", "closed"): + InquiryUpdate.objects.create( + inquiry=inquiry, + update_type="note", + message="Inquiry resolved. Response sent to the inquirer.", + created_by=inquiry.assigned_to, + created_at=created_at + timedelta(days=max(1, days_ago // 2)), + ) + + if inquiry.status == "closed": + InquiryUpdate.objects.create( + inquiry=inquiry, + update_type="note", + message="Inquiry closed after follow-up confirmation.", + created_by=inquiry.assigned_to, + created_at=created_at + timedelta(days=max(1, days_ago // 3)), + ) + + def _print_status_breakdown(self, statuses_list): + from collections import Counter + + counts = Counter(statuses_list) + for status, count in sorted(counts.items()): + self.stdout.write(f" {status}: {count}") + self.stdout.write("") + + def _print_summary(self, complaints, observations, inquiries): + self.stdout.write(f"\n{'=' * 60}") + self.stdout.write("Summary:") + self.stdout.write(f" Complaints: {len(complaints)}") + self.stdout.write(f" Observations: {len(observations)}") + self.stdout.write(f" Inquiries: {len(inquiries)}") + self.stdout.write(f" Total: {len(complaints) + len(observations) + len(inquiries)}") + self.stdout.write(f"{'=' * 60}") + + if self.dry_run: + self.stdout.write(self.style.WARNING("\nDRY RUN: No changes were made\n")) + else: + self.stdout.write(self.style.SUCCESS("\nSample data seeding completed successfully!\n")) + + +PX_SOURCE_MAP = { + "patient": ("Patient", "مريض"), + "family": ("Family Member", "عضو العائلة"), + "staff": ("Staff", "موظف"), + "call_center": ("Call Center", "مركز الاتصال"), + "online": ("Online Form", "نموذج عبر الإنترنت"), + "in_person": ("In Person", "شخصياً"), + "survey": ("Survey", "استبيان"), + "social_media": ("Social Media", "وسائل التواصل الاجتماعي"), +} diff --git a/apps/complaints/models.py b/apps/complaints/models.py index e37e260..f03d796 100644 --- a/apps/complaints/models.py +++ b/apps/complaints/models.py @@ -28,6 +28,8 @@ class ComplaintStatus(models.TextChoices): RESOLVED = "resolved", "Resolved" CLOSED = "closed", "Closed" CANCELLED = "cancelled", "Cancelled" + CONTACTED = "contacted", "Contacted" + CONTACTED_NO_RESPONSE = "contacted_no_response", "Contacted, No Response" class ResolutionCategory(models.TextChoices): @@ -126,14 +128,11 @@ class ComplaintCategory(UUIDModel, TimeStampedModel): level = models.IntegerField( choices=LevelChoices.choices, - help_text="Hierarchy level (1=Domain, 2=Category, 3=Subcategory, 4=Classification)" + help_text="Hierarchy level (1=Domain, 2=Category, 3=Subcategory, 4=Classification)", ) domain_type = models.CharField( - max_length=20, - choices=DomainTypeChoices.choices, - blank=True, - help_text="Domain type for top-level categories" + max_length=20, choices=DomainTypeChoices.choices, blank=True, help_text="Domain type for top-level categories" ) order = models.IntegerField(default=0, help_text="Display order") @@ -150,14 +149,14 @@ class ComplaintCategory(UUIDModel, TimeStampedModel): def __str__(self): level_display = self.get_level_display() hospital_count = self.hospitals.count() - + if hospital_count == 0: hospital_info = "System-wide" elif hospital_count == 1: hospital_info = self.hospitals.first().name else: hospital_info = f"{hospital_count} hospitals" - + if self.level == self.LevelChoices.CLASSIFICATION and self.parent: parent_path = " > ".join([self.parent.name_en]) return f"{level_display}: {parent_path} > {self.name_en}" @@ -190,51 +189,37 @@ class Complaint(UUIDModel, TimeStampedModel): contact_name = models.CharField(max_length=200, blank=True) contact_phone = models.CharField(max_length=20, blank=True) contact_email = models.EmailField(blank=True) - + # Public complaint form fields relation_to_patient = models.CharField( max_length=20, choices=[ - ('patient', 'Patient'), - ('relative', 'Relative'), + ("patient", "Patient"), + ("relative", "Relative"), ], blank=True, verbose_name="Relation to Patient", - help_text="Complainant's relationship to the patient" + help_text="Complainant's relationship to the patient", ) - + patient_name = models.CharField( - max_length=200, - blank=True, - verbose_name="Patient Name", - help_text="Name of the patient involved" + max_length=200, blank=True, verbose_name="Patient Name", help_text="Name of the patient involved" ) - + national_id = models.CharField( - max_length=20, - blank=True, - verbose_name="National ID/Iqama No.", - help_text="Saudi National ID or Iqama number" + max_length=20, blank=True, verbose_name="National ID/Iqama No.", help_text="Saudi National ID or Iqama number" ) - + incident_date = models.DateField( - null=True, - blank=True, - verbose_name="Incident Date", - help_text="Date when the incident occurred" + null=True, blank=True, verbose_name="Incident Date", help_text="Date when the incident occurred" ) - + staff_name = models.CharField( - max_length=200, - blank=True, - verbose_name="Staff Involved", - help_text="Name of staff member involved (if known)" + max_length=200, blank=True, verbose_name="Staff Involved", help_text="Name of staff member involved (if known)" ) - + expected_result = models.TextField( - blank=True, - verbose_name="Expected Complaint Result", - help_text="What the complainant expects as a resolution" + blank=True, verbose_name="Expected Complaint Result", help_text="What the complainant expects as a resolution" ) # Reference number for tracking @@ -264,52 +249,73 @@ class Complaint(UUIDModel, TimeStampedModel): title = models.CharField(max_length=500) description = models.TextField() + ai_brief_en = models.CharField( + max_length=100, blank=True, db_index=True, help_text="AI-generated 2-3 word summary in English" + ) + ai_brief_ar = models.CharField(max_length=100, blank=True, help_text="AI-generated 2-3 word summary in Arabic") + # Classification - 4-level SHCT taxonomy domain = models.ForeignKey( - ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_domain", - null=True, blank=True, help_text="Level 1: Domain" + ComplaintCategory, + on_delete=models.PROTECT, + related_name="complaints_domain", + null=True, + blank=True, + help_text="Level 1: Domain", ) category = models.ForeignKey( - ComplaintCategory, on_delete=models.PROTECT, related_name="complaints", - null=True, blank=True, help_text="Level 2: Category" + ComplaintCategory, + on_delete=models.PROTECT, + related_name="complaints", + null=True, + blank=True, + help_text="Level 2: Category", ) # Keep CharField for backward compatibility (stores the code) subcategory = models.CharField(max_length=100, blank=True, help_text="Level 3: Subcategory code (legacy)") classification = models.CharField(max_length=100, blank=True, help_text="Level 4: Classification code (legacy)") # New FK fields for proper relationships subcategory_obj = models.ForeignKey( - ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_subcategory", - null=True, blank=True, help_text="Level 3: Subcategory" + ComplaintCategory, + on_delete=models.PROTECT, + related_name="complaints_subcategory", + null=True, + blank=True, + help_text="Level 3: Subcategory", ) classification_obj = models.ForeignKey( - ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_classification", - null=True, blank=True, help_text="Level 4: Classification" + ComplaintCategory, + on_delete=models.PROTECT, + related_name="complaints_classification", + null=True, + blank=True, + help_text="Level 4: Classification", ) # Location hierarchy - required fields location = models.ForeignKey( - 'organizations.Location', + "organizations.Location", on_delete=models.PROTECT, - related_name='complaints', + related_name="complaints", null=True, blank=True, - help_text="Location (e.g., Riyadh, Jeddah)" + help_text="Location (e.g., Riyadh, Jeddah)", ) main_section = models.ForeignKey( - 'organizations.MainSection', + "organizations.MainSection", on_delete=models.PROTECT, - related_name='complaints', + related_name="complaints", null=True, blank=True, - help_text="Section/Department" + help_text="Section/Department", ) subsection = models.ForeignKey( - 'organizations.SubSection', + "organizations.SubSection", on_delete=models.PROTECT, - related_name='complaints', + related_name="complaints", null=True, blank=True, - help_text="Subsection within the section" + help_text="Subsection within the section", ) # Type (complaint vs appreciation) @@ -318,7 +324,7 @@ class Complaint(UUIDModel, TimeStampedModel): choices=ComplaintType.choices, default=ComplaintType.COMPLAINT, db_index=True, - help_text="Type of feedback (complaint vs appreciation)" + help_text="Type of feedback (complaint vs appreciation)", ) # Source type (Internal vs External) @@ -327,7 +333,7 @@ class Complaint(UUIDModel, TimeStampedModel): choices=ComplaintSourceType.choices, default=ComplaintSourceType.EXTERNAL, db_index=True, - help_text="Source type (Internal = staff-generated, External = patient/public-generated)" + help_text="Source type (Internal = staff-generated, External = patient/public-generated)", ) # Priority and severity @@ -350,17 +356,17 @@ class Complaint(UUIDModel, TimeStampedModel): # Creator tracking created_by = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='created_complaints', - help_text="User who created this complaint (SourceUser or Patient)" + related_name="created_complaints", + help_text="User who created this complaint (SourceUser or Patient)", ) # Status and workflow status = models.CharField( - max_length=20, choices=ComplaintStatus.choices, default=ComplaintStatus.OPEN, db_index=True + max_length=25, choices=ComplaintStatus.choices, default=ComplaintStatus.OPEN, db_index=True ) # Assignment @@ -371,39 +377,49 @@ class Complaint(UUIDModel, TimeStampedModel): # Activation tracking activated_at = models.DateTimeField( - null=True, - blank=True, + null=True, + blank=True, db_index=True, - help_text="Timestamp when complaint was first activated (moved from OPEN to IN_PROGRESS)" + help_text="Timestamp when complaint was first activated (moved from OPEN to IN_PROGRESS)", ) # SLA tracking due_at = models.DateTimeField(db_index=True, help_text="SLA deadline") is_overdue = models.BooleanField(default=False, db_index=True) + breached_at = models.DateTimeField( + null=True, blank=True, db_index=True, help_text="Timestamp when complaint first breached SLA" + ) reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="First SLA reminder timestamp") second_reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="Second SLA reminder timestamp") escalated_at = models.DateTimeField(null=True, blank=True) + # Explanation tracking + explanation_requested = models.BooleanField( + default=False, help_text="Whether an explanation has been requested from staff" + ) + explanation_requested_at = models.DateTimeField( + null=True, blank=True, help_text="When explanation request was first sent to staff" + ) + explanation_received_at = models.DateTimeField( + null=True, blank=True, help_text="When explanation was received from staff" + ) + explanation_delay_reason = models.TextField(blank=True, help_text="Reason for delay in receiving staff explanation") + # Resolution resolution = models.TextField(blank=True) resolution_sent_at = models.DateTimeField(null=True, blank=True) resolution_category = models.CharField( - max_length=50, - choices=ResolutionCategory.choices, - blank=True, - db_index=True, - help_text="Category of resolution" + max_length=50, choices=ResolutionCategory.choices, blank=True, db_index=True, help_text="Category of resolution" ) resolution_outcome = models.CharField( max_length=20, choices=ResolutionOutcome.choices, blank=True, db_index=True, - help_text="Who was in wrong/right (Patient / Hospital / Other)" + help_text="Who was in wrong/right (Patient / Hospital / Other)", ) resolution_outcome_other = models.TextField( - blank=True, - help_text="Specify if Other was selected for resolution outcome" + blank=True, help_text="Specify if Other was selected for resolution outcome" ) resolved_at = models.DateTimeField(null=True, blank=True) resolved_by = models.ForeignKey( @@ -422,6 +438,52 @@ class Complaint(UUIDModel, TimeStampedModel): ) resolution_survey_sent_at = models.DateTimeField(null=True, blank=True) + # Direct satisfaction tracking (from calls/follow-ups) + satisfaction = models.CharField( + max_length=20, + choices=[ + ("satisfied", "Satisfied"), + ("neutral", "Neutral"), + ("dissatisfied", "Dissatisfied"), + ("no_response", "No Response"), + ("escalated", "Escalated"), + ], + blank=True, + db_index=True, + help_text="Direct satisfaction feedback from patient follow-up call", + ) + + # External references + moh_reference = models.CharField(max_length=100, blank=True, help_text="Ministry of Health reference number") + moh_reference_date = models.DateField(null=True, blank=True, help_text="MOH reference date") + chi_reference = models.CharField( + max_length=100, blank=True, help_text="Council of Health Insurance reference number" + ) + chi_reference_date = models.DateField(null=True, blank=True, help_text="CHI reference date") + + # File number (patient MRN) + file_number = models.CharField(max_length=100, blank=True, db_index=True, help_text="Patient file/MRN number") + + # Workflow timeline (Step 1 fields) + form_sent_at = models.DateTimeField( + null=True, blank=True, help_text="When complaint form was sent to the complained department" + ) + forwarded_to_dept_at = models.DateTimeField( + null=True, blank=True, help_text="When complaint was forwarded to the involved department" + ) + response_date = models.DateField(null=True, blank=True, help_text="Date when response was received") + + # Complaint details (Step 1 fields) + complaint_subject = models.CharField( + max_length=500, blank=True, help_text="Main complaint subject (from Excel classification)" + ) + action_taken_by_dept = models.TextField(blank=True, help_text="Action taken by the responsible department") + action_result = models.TextField(blank=True, help_text="Result of the action/investigation taken") + recommendation_action_plan = models.TextField(blank=True, help_text="Solutions, suggestions, and action plan") + delay_reason_closure = models.TextField( + blank=True, help_text="Reason for not closing the complaint within 72 hours" + ) + # Metadata metadata = models.JSONField(default=dict, blank=True) @@ -446,27 +508,28 @@ class Complaint(UUIDModel, TimeStampedModel): self._status_was = old_instance.status except Complaint.DoesNotExist: self._status_was = None - + # Generate reference number if not set (for all creation methods: form, API, admin) if not self.reference_number: from datetime import datetime import uuid + today = datetime.now().strftime("%Y%m%d") random_suffix = str(uuid.uuid4().int)[:6] self.reference_number = f"CMP-{today}-{random_suffix}" - + if not self.due_at: self.due_at = self.calculate_sla_due_date() - + # Sync complaint_type from AI metadata if not already set # This ensures that model field stays in sync with AI classification - if self.metadata and 'ai_analysis' in self.metadata: - ai_complaint_type = self.metadata['ai_analysis'].get('complaint_type', 'complaint') + if self.metadata and "ai_analysis" in self.metadata: + ai_complaint_type = self.metadata["ai_analysis"].get("complaint_type", "complaint") # Only sync if model field is still default 'complaint' # This preserves any manual changes while fixing AI-synced complaints - if self.complaint_type == 'complaint' and ai_complaint_type != 'complaint': + if self.complaint_type == "complaint" and ai_complaint_type != "complaint": self.complaint_type = ai_complaint_type - + super().save(*args, **kwargs) def calculate_sla_due_date(self): @@ -483,11 +546,7 @@ class Complaint(UUIDModel, TimeStampedModel): # Try source-based SLA config first if self.source: try: - sla_config = ComplaintSLAConfig.objects.get( - hospital=self.hospital, - source=self.source, - is_active=True - ) + sla_config = ComplaintSLAConfig.objects.get(hospital=self.hospital, source=self.source, is_active=True) sla_hours = sla_config.sla_hours return timezone.now() + timedelta(hours=sla_hours) except ComplaintSLAConfig.DoesNotExist: @@ -500,7 +559,7 @@ class Complaint(UUIDModel, TimeStampedModel): source__isnull=True, # Explicitly check for null source severity=self.severity, priority=self.priority, - is_active=True + is_active=True, ) sla_hours = sla_config.sla_hours return timezone.now() + timedelta(hours=sla_hours) @@ -510,10 +569,7 @@ class Complaint(UUIDModel, TimeStampedModel): # Try severity/priority-based config without source filter (backward compatibility) try: sla_config = ComplaintSLAConfig.objects.get( - hospital=self.hospital, - severity=self.severity, - priority=self.priority, - is_active=True + hospital=self.hospital, severity=self.severity, priority=self.priority, is_active=True ) sla_hours = sla_config.sla_hours return timezone.now() + timedelta(hours=sla_hours) @@ -521,9 +577,7 @@ class Complaint(UUIDModel, TimeStampedModel): pass # Fall back to settings # Fall back to settings defaults - sla_hours = settings.SLA_DEFAULTS["complaint"].get( - self.severity, settings.SLA_DEFAULTS["complaint"]["medium"] - ) + sla_hours = settings.SLA_DEFAULTS["complaint"].get(self.severity, settings.SLA_DEFAULTS["complaint"]["medium"]) return timezone.now() + timedelta(hours=sla_hours) @@ -537,11 +591,7 @@ class Complaint(UUIDModel, TimeStampedModel): # Try source-based SLA config first if self.source: try: - return ComplaintSLAConfig.objects.get( - hospital=self.hospital, - source=self.source, - is_active=True - ) + return ComplaintSLAConfig.objects.get(hospital=self.hospital, source=self.source, is_active=True) except ComplaintSLAConfig.DoesNotExist: pass # Fall through to next option @@ -552,7 +602,7 @@ class Complaint(UUIDModel, TimeStampedModel): source__isnull=True, severity=self.severity, priority=self.priority, - is_active=True + is_active=True, ) except ComplaintSLAConfig.DoesNotExist: pass # Fall through to next option @@ -560,10 +610,7 @@ class Complaint(UUIDModel, TimeStampedModel): # Try severity/priority-based config without source filter (backward compatibility) try: return ComplaintSLAConfig.objects.get( - hospital=self.hospital, - severity=self.severity, - priority=self.priority, - is_active=True + hospital=self.hospital, severity=self.severity, priority=self.priority, is_active=True ) except ComplaintSLAConfig.DoesNotExist: pass # No config found @@ -578,7 +625,8 @@ class Complaint(UUIDModel, TimeStampedModel): if timezone.now() > self.due_at: if not self.is_overdue: self.is_overdue = True - self.save(update_fields=["is_overdue"]) + self.breached_at = timezone.now() + self.save(update_fields=["is_overdue", "breached_at"]) return True return False @@ -589,11 +637,7 @@ class Complaint(UUIDModel, TimeStampedModel): Active statuses: OPEN, IN_PROGRESS, PARTIALLY_RESOLVED Inactive statuses: RESOLVED, CLOSED, CANCELLED """ - return self.status in [ - ComplaintStatus.OPEN, - ComplaintStatus.IN_PROGRESS, - ComplaintStatus.PARTIALLY_RESOLVED - ] + return self.status in [ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.PARTIALLY_RESOLVED] @property def short_description_en(self): @@ -655,11 +699,7 @@ class Complaint(UUIDModel, TimeStampedModel): # Fallback: convert old single action to list format single_action = self.metadata["ai_analysis"].get("suggested_action_en", "") if single_action: - return [{ - "action": single_action, - "priority": "medium", - "category": "process_improvement" - }] + return [{"action": single_action, "priority": "medium", "category": "process_improvement"}] return [] @property @@ -748,22 +788,23 @@ class Complaint(UUIDModel, TimeStampedModel): @property def is_activated(self): return self.activated_at is not None + def get_tracking_url(self): """ Get the public tracking URL for this complaint. - + Returns the full URL that complainants can use to track their complaint status. """ from django.contrib.sites.shortcuts import get_current_site from django.urls import reverse - + # Build absolute URL try: site = get_current_site(None) domain = site.domain except: - domain = 'localhost:8000' - + domain = "localhost:8000" + return f"https://{domain}{reverse('complaints:public_complaint_track')}?reference={self.reference_number}" @@ -821,8 +862,8 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel): ) # Status change tracking - old_status = models.CharField(max_length=20, blank=True) - new_status = models.CharField(max_length=20, blank=True) + old_status = models.CharField(max_length=25, blank=True) + new_status = models.CharField(max_length=25, blank=True) # Metadata metadata = models.JSONField(default=dict, blank=True) @@ -855,7 +896,7 @@ class ComplaintSLAConfig(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="complaint_sla_configs", - help_text="Complaint source (MOH, CHI, Patient, etc.). Empty = severity/priority-based config" + help_text="Complaint source (MOH, CHI, Patient, etc.). Empty = severity/priority-based config", ) severity = models.CharField( @@ -863,7 +904,7 @@ class ComplaintSLAConfig(UUIDModel, TimeStampedModel): choices=SeverityChoices.choices, null=True, blank=True, - help_text="Severity level for this SLA (optional if source is specified)" + help_text="Severity level for this SLA (optional if source is specified)", ) priority = models.CharField( @@ -871,25 +912,22 @@ class ComplaintSLAConfig(UUIDModel, TimeStampedModel): choices=PriorityChoices.choices, null=True, blank=True, - help_text="Priority level for this SLA (optional if source is specified)" + help_text="Priority level for this SLA (optional if source is specified)", ) sla_hours = models.IntegerField(help_text="Number of hours until SLA deadline") # Source-based reminder timing (from complaint creation) first_reminder_hours_after = models.IntegerField( - default=0, - help_text="Send 1st reminder X hours after complaint creation (0 = use reminder_hours_before)" + default=0, help_text="Send 1st reminder X hours after complaint creation (0 = use reminder_hours_before)" ) second_reminder_hours_after = models.IntegerField( - default=0, - help_text="Send 2nd reminder X hours after complaint creation (0 = use second_reminder_hours_before)" + default=0, help_text="Send 2nd reminder X hours after complaint creation (0 = use second_reminder_hours_before)" ) escalation_hours_after = models.IntegerField( - default=0, - help_text="Escalate complaint X hours after creation if unresolved (0 = use overdue logic)" + default=0, help_text="Escalate complaint X hours after creation if unresolved (0 = use overdue logic)" ) # Legacy reminder timing (before deadline - kept for backward compatibility) @@ -898,10 +936,14 @@ class ComplaintSLAConfig(UUIDModel, TimeStampedModel): # Second reminder configuration second_reminder_enabled = models.BooleanField(default=False, help_text="Enable sending a second reminder") - second_reminder_hours_before = models.IntegerField(default=6, help_text="Send second reminder X hours before deadline") + second_reminder_hours_before = models.IntegerField( + default=6, help_text="Send second reminder X hours before deadline" + ) # Thank you email configuration - thank_you_email_enabled = models.BooleanField(default=False, help_text="Send thank you email when complaint is closed") + thank_you_email_enabled = models.BooleanField( + default=False, help_text="Send thank you email when complaint is closed" + ) is_active = models.BooleanField(default=True) @@ -966,14 +1008,10 @@ class EscalationRule(UUIDModel, TimeStampedModel): description = models.TextField(blank=True) # Escalation level (supports multi-level escalation) - escalation_level = models.IntegerField( - default=1, - help_text="Escalation level (1 = first level, 2 = second, etc.)" - ) + escalation_level = models.IntegerField(default=1, help_text="Escalation level (1 = first level, 2 = second, etc.)") max_escalation_level = models.IntegerField( - default=3, - help_text="Maximum escalation level before stopping (default: 3)" + default=3, help_text="Maximum escalation level before stopping (default: 3)" ) # Trigger conditions @@ -983,13 +1021,11 @@ class EscalationRule(UUIDModel, TimeStampedModel): # Reminder-based escalation reminder_escalation_enabled = models.BooleanField( - default=False, - help_text="Enable escalation after reminder if no action taken" + default=False, help_text="Enable escalation after reminder if no action taken" ) reminder_escalation_hours = models.IntegerField( - default=24, - help_text="Escalate X hours after reminder if no action" + default=24, help_text="Escalate X hours after reminder if no action" ) # Escalation target @@ -1127,37 +1163,32 @@ class ExplanationSLAConfig(UUIDModel, TimeStampedModel): """ hospital = models.ForeignKey( - "organizations.Hospital", - on_delete=models.CASCADE, - related_name="explanation_sla_configs" + "organizations.Hospital", on_delete=models.CASCADE, related_name="explanation_sla_configs" ) # Time limits - response_hours = models.IntegerField( - default=48, - help_text="Hours staff has to submit explanation" + response_hours = models.IntegerField(default=48, help_text="Hours staff has to submit explanation") + + reminder_hours_before = models.IntegerField(default=12, help_text="Send first reminder X hours before deadline") + + second_reminder_enabled = models.BooleanField( + default=True, help_text="Enable sending a second reminder before escalation" ) - reminder_hours_before = models.IntegerField( - default=12, - help_text="Send reminder X hours before deadline" + second_reminder_hours_before = models.IntegerField( + default=4, help_text="Send second reminder X hours before deadline" ) # Escalation settings auto_escalate_enabled = models.BooleanField( - default=True, - help_text="Automatically escalate to manager if no response" + default=True, help_text="Automatically escalate to manager if no response" ) escalation_hours_overdue = models.IntegerField( - default=0, - help_text="Escalate X hours after overdue (0 = immediately)" + default=0, help_text="Escalate X hours after overdue (0 = immediately)" ) - max_escalation_levels = models.IntegerField( - default=3, - help_text="Maximum levels to escalate up staff hierarchy" - ) + max_escalation_levels = models.IntegerField(default=3, help_text="Maximum levels to escalate up staff hierarchy") is_active = models.BooleanField(default=True) @@ -1173,6 +1204,82 @@ class ExplanationSLAConfig(UUIDModel, TimeStampedModel): return f"{self.hospital.name} - {self.response_hours}h to respond" +class InquirySLAConfig(UUIDModel, TimeStampedModel): + """ + SLA configuration for inquiries per hospital and source. + + Allows flexible SLA configuration for inquiry response times. + """ + + hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="inquiry_sla_configs") + + source = models.ForeignKey( + "px_sources.PXSource", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="inquiry_sla_configs", + help_text="Inquiry source (MOH, CHI, Patient, etc.). Empty = default config", + ) + + sla_hours = models.IntegerField(help_text="Number of hours until SLA deadline") + + first_reminder_hours_after = models.IntegerField( + default=0, help_text="Send 1st reminder X hours after inquiry creation (0 = use reminder_hours_before)" + ) + + second_reminder_hours_after = models.IntegerField( + default=0, help_text="Send 2nd reminder X hours after inquiry creation (0 = use second_reminder_hours_before)" + ) + + escalation_hours_after = models.IntegerField( + default=0, help_text="Escalate inquiry X hours after creation if unresolved (0 = use overdue logic)" + ) + + reminder_hours_before = models.IntegerField(default=24, help_text="Send first reminder X hours before deadline") + + second_reminder_enabled = models.BooleanField(default=False, help_text="Enable sending a second reminder") + + second_reminder_hours_before = models.IntegerField( + default=6, help_text="Send second reminder X hours before deadline" + ) + + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["hospital", "source"] + verbose_name = "Inquiry SLA Config" + verbose_name_plural = "Inquiry SLA Configs" + indexes = [ + models.Index(fields=["hospital", "is_active"]), + models.Index(fields=["hospital", "source", "is_active"]), + ] + + def __str__(self): + source_display = self.source.name_en if self.source else "Default" + return f"{self.hospital.name} - {source_display} - {self.sla_hours}h" + + def get_first_reminder_hours_after(self, inquiry_created_at=None): + if self.first_reminder_hours_after > 0: + return self.first_reminder_hours_after + else: + return max(0, self.sla_hours - self.reminder_hours_before) + + def get_second_reminder_hours_after(self, inquiry_created_at=None): + if self.second_reminder_hours_after > 0: + return self.second_reminder_hours_after + elif self.second_reminder_enabled: + return max(0, self.sla_hours - self.second_reminder_hours_before) + else: + return 0 + + def get_escalation_hours_after(self, inquiry_created_at=None): + if self.escalation_hours_after > 0: + return self.escalation_hours_after + else: + return None + + class Inquiry(UUIDModel, TimeStampedModel): """ Inquiry model for general questions/requests. @@ -1221,15 +1328,17 @@ class Inquiry(UUIDModel, TimeStampedModel): blank=True, help_text="Source of inquiry", ) - + # Status status = models.CharField( - max_length=20, + max_length=25, choices=[ ("open", "Open"), ("in_progress", "In Progress"), ("resolved", "Resolved"), ("closed", "Closed"), + ("contacted", "Contacted"), + ("contacted_no_response", "Contacted, No Response"), ], default="open", db_index=True, @@ -1237,12 +1346,12 @@ class Inquiry(UUIDModel, TimeStampedModel): # Creator tracking created_by = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='created_inquiries', - help_text="User who created this inquiry (SourceUser or Patient)" + related_name="created_inquiries", + help_text="User who created this inquiry (SourceUser or Patient)", ) # Assignment @@ -1251,6 +1360,24 @@ class Inquiry(UUIDModel, TimeStampedModel): ) assigned_at = models.DateTimeField(null=True, blank=True) + # Activation tracking + activated_at = models.DateTimeField( + null=True, + blank=True, + db_index=True, + help_text="Timestamp when inquiry was first activated (moved from OPEN to IN_PROGRESS)", + ) + + # SLA tracking + due_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text="SLA deadline") + is_overdue = models.BooleanField(default=False, db_index=True) + breached_at = models.DateTimeField( + null=True, blank=True, db_index=True, help_text="Timestamp when inquiry first breached SLA" + ) + reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="First SLA reminder timestamp") + second_reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="Second SLA reminder timestamp") + escalated_at = models.DateTimeField(null=True, blank=True) + # Response response = models.TextField(blank=True) responded_at = models.DateTimeField(null=True, blank=True) @@ -1262,13 +1389,11 @@ class Inquiry(UUIDModel, TimeStampedModel): is_straightforward = models.BooleanField( default=True, verbose_name="Is Straightforward", - help_text="Direct resolution (no department coordination needed)" + help_text="Direct resolution (no department coordination needed)", ) is_outgoing = models.BooleanField( - default=False, - verbose_name="Is Outgoing", - help_text="Inquiry sent to external department for response" + default=False, verbose_name="Is Outgoing", help_text="Inquiry sent to external department for response" ) outgoing_department = models.ForeignKey( @@ -1277,28 +1402,19 @@ class Inquiry(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="outgoing_inquiries", - help_text="Department that was contacted for this inquiry" + help_text="Department that was contacted for this inquiry", ) # Follow-up tracking requires_follow_up = models.BooleanField( - default=False, - db_index=True, - help_text="This inquiry requires follow-up call" + default=False, db_index=True, help_text="This inquiry requires follow-up call" ) follow_up_due_at = models.DateTimeField( - null=True, - blank=True, - db_index=True, - help_text="Due date for follow-up call to inquirer" + null=True, blank=True, db_index=True, help_text="Due date for follow-up call to inquirer" ) - follow_up_completed_at = models.DateTimeField( - null=True, - blank=True, - help_text="When follow-up call was completed" - ) + follow_up_completed_at = models.DateTimeField(null=True, blank=True, help_text="When follow-up call was completed") follow_up_completed_by = models.ForeignKey( "accounts.User", @@ -1306,18 +1422,13 @@ class Inquiry(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="completed_inquiry_followups", - help_text="User who completed the follow-up call" + help_text="User who completed the follow-up call", ) - follow_up_notes = models.TextField( - blank=True, - help_text="Notes from follow-up call" - ) + follow_up_notes = models.TextField(blank=True, help_text="Notes from follow-up call") follow_up_reminder_sent_at = models.DateTimeField( - null=True, - blank=True, - help_text="When reminder was sent for follow-up" + null=True, blank=True, help_text="When reminder was sent for follow-up" ) class Meta: @@ -1331,6 +1442,52 @@ class Inquiry(UUIDModel, TimeStampedModel): def __str__(self): return f"{self.subject} ({self.status})" + def get_sla_config(self): + """Get SLA configuration for this inquiry based on hospital and source.""" + try: + if self.source: + config = InquirySLAConfig.objects.filter( + hospital=self.hospital, + source=self.source, + is_active=True, + ).first() + if config: + return config + + config = InquirySLAConfig.objects.filter( + hospital=self.hospital, + is_active=True, + ).first() + if config: + return config + + except Exception: + pass + + return None + + def check_overdue(self): + """Check if inquiry is overdue and update status""" + if self.status in ["closed", "cancelled"]: + return False + + if self.due_at and timezone.now() > self.due_at: + if not self.is_overdue: + self.is_overdue = True + self.breached_at = timezone.now() + self.save(update_fields=["is_overdue", "breached_at"]) + return True + return False + + @property + def is_active_status(self): + """ + Check if inquiry is in an active status (can be worked on). + Active statuses: open, in_progress + Inactive statuses: resolved, closed, contacted, contacted_no_response + """ + return self.status in ["open", "in_progress"] + class InquiryUpdate(UUIDModel, TimeStampedModel): """ @@ -1362,8 +1519,8 @@ class InquiryUpdate(UUIDModel, TimeStampedModel): ) # Status change tracking - old_status = models.CharField(max_length=20, blank=True) - new_status = models.CharField(max_length=20, blank=True) + old_status = models.CharField(max_length=25, blank=True) + new_status = models.CharField(max_length=25, blank=True) # Metadata metadata = models.JSONField(default=dict, blank=True) @@ -1459,38 +1616,29 @@ class ComplaintExplanation(UUIDModel, TimeStampedModel): # SLA tracking for explanation requests sla_due_at = models.DateTimeField( - null=True, - blank=True, - db_index=True, - help_text="SLA deadline for staff to submit explanation" + null=True, blank=True, db_index=True, help_text="SLA deadline for staff to submit explanation" ) - is_overdue = models.BooleanField( - default=False, - db_index=True, - help_text="Explanation request is overdue" - ) + is_overdue = models.BooleanField(default=False, db_index=True, help_text="Explanation request is overdue") reminder_sent_at = models.DateTimeField( - null=True, - blank=True, - help_text="Reminder sent to staff about overdue explanation" + null=True, blank=True, help_text="First reminder sent to staff about overdue explanation" + ) + + second_reminder_sent_at = models.DateTimeField( + null=True, blank=True, help_text="Second reminder sent to staff about overdue explanation" ) escalated_to_manager = models.ForeignKey( - 'self', + "self", on_delete=models.SET_NULL, null=True, blank=True, - related_name='escalated_from_staff', - help_text="Escalated to this explanation (manager's explanation request)" + related_name="escalated_from_staff", + help_text="Escalated to this explanation (manager's explanation request)", ) - escalated_at = models.DateTimeField( - null=True, - blank=True, - help_text="When explanation was escalated to manager" - ) + escalated_at = models.DateTimeField(null=True, blank=True, help_text="When explanation was escalated to manager") # Acceptance review fields class AcceptanceStatus(models.TextChoices): @@ -1502,7 +1650,7 @@ class ComplaintExplanation(UUIDModel, TimeStampedModel): max_length=20, choices=AcceptanceStatus.choices, default=AcceptanceStatus.PENDING, - help_text="Review status of the explanation" + help_text="Review status of the explanation", ) accepted_by = models.ForeignKey( @@ -1511,19 +1659,12 @@ class ComplaintExplanation(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="reviewed_explanations", - help_text="User who reviewed and marked the explanation" + help_text="User who reviewed and marked the explanation", ) - accepted_at = models.DateTimeField( - null=True, - blank=True, - help_text="When the explanation was reviewed" - ) + accepted_at = models.DateTimeField(null=True, blank=True, help_text="When the explanation was reviewed") - acceptance_notes = models.TextField( - blank=True, - help_text="Notes about the acceptance decision" - ) + acceptance_notes = models.TextField(blank=True, help_text="Notes about the acceptance decision") class Meta: ordering = ["-created_at"] @@ -1584,9 +1725,7 @@ class ComplaintPRInteraction(UUIDModel, TimeStampedModel): complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="pr_interactions") - contact_date = models.DateTimeField( - help_text="Date and time of PR contact with complainant" - ) + contact_date = models.DateTimeField(help_text="Date and time of PR contact with complainant") contact_method = models.CharField( max_length=20, @@ -1597,7 +1736,7 @@ class ComplaintPRInteraction(UUIDModel, TimeStampedModel): ("other", "Other"), ], default="in_person", - help_text="Method of contact" + help_text="Method of contact", ) pr_staff = models.ForeignKey( @@ -1606,23 +1745,16 @@ class ComplaintPRInteraction(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="pr_interactions", - help_text="PR staff member who made the contact" + help_text="PR staff member who made the contact", ) - statement_text = models.TextField( - blank=True, - help_text="Formal statement taken from the complainant" - ) + statement_text = models.TextField(blank=True, help_text="Formal statement taken from the complainant") procedure_explained = models.BooleanField( - default=False, - help_text="Whether complaint procedure was explained to the complainant" + default=False, help_text="Whether complaint procedure was explained to the complainant" ) - notes = models.TextField( - blank=True, - help_text="Additional notes from the PR interaction" - ) + notes = models.TextField(blank=True, help_text="Additional notes from the PR interaction") created_by = models.ForeignKey( "accounts.User", @@ -1630,7 +1762,7 @@ class ComplaintPRInteraction(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="created_pr_interactions", - help_text="User who created this PR interaction record" + help_text="User who created this PR interaction record", ) class Meta: @@ -1656,9 +1788,7 @@ class ComplaintMeeting(UUIDModel, TimeStampedModel): complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="meetings") - meeting_date = models.DateTimeField( - help_text="Date and time of the meeting" - ) + meeting_date = models.DateTimeField(help_text="Date and time of the meeting") meeting_type = models.CharField( max_length=50, @@ -1669,18 +1799,12 @@ class ComplaintMeeting(UUIDModel, TimeStampedModel): ("other", "Other"), ], default="management_intervention", - help_text="Type of meeting" + help_text="Type of meeting", ) - outcome = models.TextField( - blank=True, - help_text="Meeting outcome and agreed resolution" - ) + outcome = models.TextField(blank=True, help_text="Meeting outcome and agreed resolution") - notes = models.TextField( - blank=True, - help_text="Additional meeting notes" - ) + notes = models.TextField(blank=True, help_text="Additional meeting notes") created_by = models.ForeignKey( "accounts.User", @@ -1688,7 +1812,7 @@ class ComplaintMeeting(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="created_meetings", - help_text="User who created this meeting record" + help_text="User who created this meeting record", ) class Meta: @@ -1704,7 +1828,6 @@ class ComplaintMeeting(UUIDModel, TimeStampedModel): return f"{self.complaint} - {type_display} - {self.meeting_date.strftime('%Y-%m-%d')}" - class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel): """ Tracks departments involved in a complaint. @@ -1719,42 +1842,26 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel): COORDINATION = "coordination", "Coordination Only" INVESTIGATING = "investigating", "Investigating" - complaint = models.ForeignKey( - Complaint, - on_delete=models.CASCADE, - related_name="involved_departments" - ) + complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="involved_departments") department = models.ForeignKey( - "organizations.Department", - on_delete=models.CASCADE, - related_name="complaint_involvements" + "organizations.Department", on_delete=models.CASCADE, related_name="complaint_involvements" ) role = models.CharField( max_length=20, choices=RoleChoices.choices, default=RoleChoices.SECONDARY, - help_text="Role of this department in the complaint resolution" + help_text="Role of this department in the complaint resolution", ) - is_primary = models.BooleanField( - default=False, - help_text="Mark as the primary responsible department" - ) + is_primary = models.BooleanField(default=False, help_text="Mark as the primary responsible department") added_by = models.ForeignKey( - "accounts.User", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="added_department_involvements" + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="added_department_involvements" ) - notes = models.TextField( - blank=True, - help_text="Additional notes about this department's involvement" - ) + notes = models.TextField(blank=True, help_text="Additional notes about this department's involvement") # Assignment within this department assigned_to = models.ForeignKey( @@ -1763,29 +1870,30 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="department_assigned_complaints", - help_text="User assigned from this department to handle the complaint" + help_text="User assigned from this department to handle the complaint", ) - assigned_at = models.DateTimeField( - null=True, - blank=True - ) + assigned_at = models.DateTimeField(null=True, blank=True) # Response tracking response_submitted = models.BooleanField( - default=False, - help_text="Whether this department has submitted their response" + default=False, help_text="Whether this department has submitted their response" ) - response_submitted_at = models.DateTimeField( - null=True, - blank=True - ) + response_submitted_at = models.DateTimeField(null=True, blank=True) - response_notes = models.TextField( - blank=True, - help_text="Department's response/feedback on the complaint" + response_notes = models.TextField(blank=True, help_text="Department's response/feedback on the complaint") + + # Reminder and delay tracking (Step 1 fields) + forwarded_at = models.DateTimeField(null=True, blank=True, help_text="When complaint was sent to this department") + first_reminder_sent_at = models.DateTimeField( + null=True, blank=True, help_text="When first reminder was sent to this department" ) + second_reminder_sent_at = models.DateTimeField( + null=True, blank=True, help_text="When second reminder was sent to this department" + ) + delay_reason = models.TextField(blank=True, help_text="Reason for department delay in response") + delayed_person = models.CharField(max_length=200, blank=True, help_text="Name of person responsible for delay") class Meta: ordering = ["-is_primary", "-created_at"] @@ -1795,6 +1903,7 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel): indexes = [ models.Index(fields=["complaint", "role"]), models.Index(fields=["department", "response_submitted"]), + models.Index(fields=["department", "forwarded_at"]), ] def __str__(self): @@ -1806,10 +1915,9 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel): """Ensure only one primary department per complaint""" if self.is_primary: # Clear primary flag from other departments for this complaint - ComplaintInvolvedDepartment.objects.filter( - complaint=self.complaint, - is_primary=True - ).exclude(pk=self.pk).update(is_primary=False) + ComplaintInvolvedDepartment.objects.filter(complaint=self.complaint, is_primary=True).exclude( + pk=self.pk + ).update(is_primary=False) super().save(*args, **kwargs) @@ -1829,63 +1937,35 @@ class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel): SUPPORT = "support", "Support Staff" COORDINATOR = "coordinator", "Coordinator" - complaint = models.ForeignKey( - Complaint, - on_delete=models.CASCADE, - related_name="involved_staff" - ) + complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="involved_staff") - staff = models.ForeignKey( - "organizations.Staff", - on_delete=models.CASCADE, - related_name="complaint_involvements" - ) + staff = models.ForeignKey("organizations.Staff", on_delete=models.CASCADE, related_name="complaint_involvements") role = models.CharField( max_length=20, choices=RoleChoices.choices, default=RoleChoices.ACCUSED, - help_text="Role of this staff member in the complaint" + help_text="Role of this staff member in the complaint", ) added_by = models.ForeignKey( - "accounts.User", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="added_staff_involvements" + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="added_staff_involvements" ) - notes = models.TextField( - blank=True, - help_text="Additional notes about this staff member's involvement" - ) + notes = models.TextField(blank=True, help_text="Additional notes about this staff member's involvement") # Explanation tracking explanation_requested = models.BooleanField( - default=False, - help_text="Whether an explanation has been requested from this staff" + default=False, help_text="Whether an explanation has been requested from this staff" ) - explanation_requested_at = models.DateTimeField( - null=True, - blank=True - ) + explanation_requested_at = models.DateTimeField(null=True, blank=True) - explanation_received = models.BooleanField( - default=False, - help_text="Whether an explanation has been received" - ) + explanation_received = models.BooleanField(default=False, help_text="Whether an explanation has been received") - explanation_received_at = models.DateTimeField( - null=True, - blank=True - ) + explanation_received_at = models.DateTimeField(null=True, blank=True) - explanation = models.TextField( - blank=True, - help_text="The staff member's explanation" - ) + explanation = models.TextField(blank=True, help_text="The staff member's explanation") class Meta: ordering = ["role", "-created_at"] @@ -1905,41 +1985,29 @@ class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel): class OnCallAdminSchedule(UUIDModel, TimeStampedModel): """ On-call admin schedule configuration for complaint notifications. - + Manages which PX Admins should be notified outside of working hours. During working hours, ALL PX Admins are notified. Outside working hours, only ON-CALL admins are notified. """ - + # Working days configuration (stored as list of day numbers: 0=Monday, 6=Sunday) working_days = models.JSONField( - default=list, - help_text="List of working days (0=Monday, 6=Sunday). Default: [0,1,2,3,4] (Mon-Fri)" + default=list, help_text="List of working days (0=Monday, 6=Sunday). Default: [0,1,2,3,4] (Mon-Fri)" ) - + # Working hours - work_start_time = models.TimeField( - default="08:00", - help_text="Start of working hours (e.g., 08:00)" - ) - work_end_time = models.TimeField( - default="17:00", - help_text="End of working hours (e.g., 17:00)" - ) - + work_start_time = models.TimeField(default="08:00", help_text="Start of working hours (e.g., 08:00)") + work_end_time = models.TimeField(default="17:00", help_text="End of working hours (e.g., 17:00)") + # Timezone for the schedule timezone = models.CharField( - max_length=50, - default="Asia/Riyadh", - help_text="Timezone for working hours calculation (e.g., Asia/Riyadh)" + max_length=50, default="Asia/Riyadh", help_text="Timezone for working hours calculation (e.g., Asia/Riyadh)" ) - + # Whether this config is active - is_active = models.BooleanField( - default=True, - help_text="Whether this on-call schedule is active" - ) - + is_active = models.BooleanField(default=True, help_text="Whether this on-call schedule is active") + # Hospital scope (null = system-wide) hospital = models.ForeignKey( "organizations.Hospital", @@ -1947,187 +2015,165 @@ class OnCallAdminSchedule(UUIDModel, TimeStampedModel): null=True, blank=True, related_name="on_call_schedules", - help_text="Hospital scope. Leave empty for system-wide configuration." + help_text="Hospital scope. Leave empty for system-wide configuration.", ) - + class Meta: ordering = ["-created_at"] verbose_name = "On-Call Admin Schedule" verbose_name_plural = "On-Call Admin Schedules" constraints = [ models.UniqueConstraint( - fields=['hospital'], - condition=models.Q(hospital__isnull=False), - name='unique_oncall_per_hospital' + fields=["hospital"], condition=models.Q(hospital__isnull=False), name="unique_oncall_per_hospital" ), models.UniqueConstraint( - fields=['hospital'], - condition=models.Q(hospital__isnull=True), - name='unique_system_wide_oncall' + fields=["hospital"], condition=models.Q(hospital__isnull=True), name="unique_system_wide_oncall" ), ] - + def __str__(self): scope = f"{self.hospital.name}" if self.hospital else "System-wide" - start_time = self.work_start_time.strftime('%H:%M') if hasattr(self.work_start_time, 'strftime') else str(self.work_start_time)[:5] - end_time = self.work_end_time.strftime('%H:%M') if hasattr(self.work_end_time, 'strftime') else str(self.work_end_time)[:5] + start_time = ( + self.work_start_time.strftime("%H:%M") + if hasattr(self.work_start_time, "strftime") + else str(self.work_start_time)[:5] + ) + end_time = ( + self.work_end_time.strftime("%H:%M") + if hasattr(self.work_end_time, "strftime") + else str(self.work_end_time)[:5] + ) return f"On-Call Schedule - {scope} ({start_time}-{end_time})" - + def get_working_days_list(self): """Get list of working days, with default if empty""" if self.working_days: return self.working_days return [0, 1, 2, 3, 4] # Default: Monday-Friday - + def is_working_time(self, check_datetime=None): """ Check if the given datetime is within working hours. - + Args: check_datetime: datetime to check (default: now) - + Returns: bool: True if within working hours, False otherwise """ import pytz from datetime import time as datetime_time - + if check_datetime is None: check_datetime = timezone.now() - + # Convert to schedule timezone tz = pytz.timezone(self.timezone) if timezone.is_aware(check_datetime): local_time = check_datetime.astimezone(tz) else: local_time = check_datetime.replace(tzinfo=tz) - + # Check if it's a working day working_days = self.get_working_days_list() if local_time.weekday() not in working_days: return False - + # Check if it's within working hours current_time = local_time.time() - + # Ensure work_start_time and work_end_time are time objects start_time = self.work_start_time end_time = self.work_end_time - + # Convert string to time if needed (handles string defaults from DB) if isinstance(start_time, str): - parts = start_time.split(':') + parts = start_time.split(":") hours, minutes = int(parts[0]), int(parts[1]) start_time = datetime_time(hours, minutes) - + if isinstance(end_time, str): - parts = end_time.split(':') + parts = end_time.split(":") hours, minutes = int(parts[0]), int(parts[1]) end_time = datetime_time(hours, minutes) - + return start_time <= current_time < end_time class OnCallAdmin(UUIDModel, TimeStampedModel): """ Individual on-call admin assignment. - + Links PX Admin users to an on-call schedule. """ - - schedule = models.ForeignKey( - OnCallAdminSchedule, - on_delete=models.CASCADE, - related_name="on_call_admins" - ) - + + schedule = models.ForeignKey(OnCallAdminSchedule, on_delete=models.CASCADE, related_name="on_call_admins") + admin_user = models.ForeignKey( "accounts.User", on_delete=models.CASCADE, related_name="on_call_schedules", help_text="PX Admin user who is on-call", - limit_choices_to={'groups__name': 'PX Admin'} + limit_choices_to={"groups__name": "PX Admin"}, ) - + # Optional: date range for this on-call assignment - start_date = models.DateField( - null=True, - blank=True, - help_text="Start date for this on-call assignment (optional)" - ) - - end_date = models.DateField( - null=True, - blank=True, - help_text="End date for this on-call assignment (optional)" - ) - + start_date = models.DateField(null=True, blank=True, help_text="Start date for this on-call assignment (optional)") + + end_date = models.DateField(null=True, blank=True, help_text="End date for this on-call assignment (optional)") + # Priority/order for notifications (lower = higher priority) - notification_priority = models.PositiveIntegerField( - default=1, - help_text="Priority for notifications (1 = highest)" - ) - - is_active = models.BooleanField( - default=True, - help_text="Whether this on-call assignment is currently active" - ) - + notification_priority = models.PositiveIntegerField(default=1, help_text="Priority for notifications (1 = highest)") + + is_active = models.BooleanField(default=True, help_text="Whether this on-call assignment is currently active") + # Contact preferences for out-of-hours - notify_email = models.BooleanField( - default=True, - help_text="Send email notifications" - ) - notify_sms = models.BooleanField( - default=False, - help_text="Send SMS notifications" - ) - + notify_email = models.BooleanField(default=True, help_text="Send email notifications") + notify_sms = models.BooleanField(default=False, help_text="Send SMS notifications") + # Custom phone for SMS (optional, uses user's phone if not set) sms_phone = models.CharField( - max_length=20, - blank=True, - help_text="Custom phone number for SMS notifications (optional)" + max_length=20, blank=True, help_text="Custom phone number for SMS notifications (optional)" ) - + class Meta: ordering = ["notification_priority", "-created_at"] verbose_name = "On-Call Admin" verbose_name_plural = "On-Call Admins" unique_together = [["schedule", "admin_user"]] - + def __str__(self): return f"{self.admin_user.get_full_name() or self.admin_user.email} - On-Call ({self.schedule})" - + def is_currently_active(self, check_date=None): """ Check if this on-call assignment is active for the given date. - + Args: check_date: date to check (default: today) - + Returns: bool: True if active, False otherwise """ if not self.is_active: return False - + if check_date is None: check_date = timezone.now().date() - + # Check date range if self.start_date and check_date < self.start_date: return False if self.end_date and check_date > self.end_date: return False - + return True - + def get_notification_phone(self): """Get phone number for SMS notifications""" if self.sms_phone: return self.sms_phone - if hasattr(self.admin_user, 'phone') and self.admin_user.phone: + if hasattr(self.admin_user, "phone") and self.admin_user.phone: return self.admin_user.phone return None @@ -2135,10 +2181,10 @@ class OnCallAdmin(UUIDModel, TimeStampedModel): class ComplaintAdverseAction(UUIDModel, TimeStampedModel): """ Tracks adverse actions or damages to patients related to complaints. - + This model helps identify and address retaliation or negative treatment that patients may experience after filing a complaint. - + Examples: - Doctor refusing to see the patient in subsequent visits - Delayed or denied treatment @@ -2147,9 +2193,10 @@ class ComplaintAdverseAction(UUIDModel, TimeStampedModel): - Unnecessary procedures - Dismissal from care """ - + class ActionType(models.TextChoices): """Types of adverse actions""" + REFUSED_SERVICE = "refused_service", _("Refused Service") DELAYED_TREATMENT = "delayed_treatment", _("Delayed Treatment") VERBAL_ABUSE = "verbal_abuse", _("Verbal Abuse / Hostility") @@ -2160,146 +2207,119 @@ class ComplaintAdverseAction(UUIDModel, TimeStampedModel): DISCRIMINATION = "discrimination", _("Discrimination") RETALIATION = "retaliation", _("Retaliation") OTHER = "other", _("Other") - + class SeverityLevel(models.TextChoices): """Severity levels for adverse actions""" + LOW = "low", _("Low - Minor inconvenience") MEDIUM = "medium", _("Medium - Moderate impact") HIGH = "high", _("High - Significant harm") CRITICAL = "critical", _("Critical - Severe harm / Life-threatening") - + class VerificationStatus(models.TextChoices): """Verification status of the adverse action report""" + REPORTED = "reported", _("Reported - Awaiting Review") UNDER_INVESTIGATION = "under_investigation", _("Under Investigation") VERIFIED = "verified", _("Verified") UNFOUNDED = "unfounded", _("Unfounded") RESOLVED = "resolved", _("Resolved") - + # Link to complaint complaint = models.ForeignKey( Complaint, on_delete=models.CASCADE, related_name="adverse_actions", - help_text=_("The complaint this adverse action is related to") + help_text=_("The complaint this adverse action is related to"), ) - + # Action details action_type = models.CharField( - max_length=30, - choices=ActionType.choices, - default=ActionType.OTHER, - help_text=_("Type of adverse action") + max_length=30, choices=ActionType.choices, default=ActionType.OTHER, help_text=_("Type of adverse action") ) - + severity = models.CharField( max_length=10, choices=SeverityLevel.choices, default=SeverityLevel.MEDIUM, - help_text=_("Severity level of the adverse action") + help_text=_("Severity level of the adverse action"), ) - - description = models.TextField( - help_text=_("Detailed description of what happened to the patient") - ) - + + description = models.TextField(help_text=_("Detailed description of what happened to the patient")) + # When it occurred - incident_date = models.DateTimeField( - help_text=_("Date and time when the adverse action occurred") - ) - + incident_date = models.DateTimeField(help_text=_("Date and time when the adverse action occurred")) + # Location/Context location = models.CharField( - max_length=200, - blank=True, - help_text=_("Location where the incident occurred (e.g., Emergency Room, Clinic B)") + max_length=200, blank=True, help_text=_("Location where the incident occurred (e.g., Emergency Room, Clinic B)") ) - + # Staff involved involved_staff = models.ManyToManyField( "organizations.Staff", blank=True, related_name="adverse_actions_involved", - help_text=_("Staff members involved in the adverse action") + help_text=_("Staff members involved in the adverse action"), ) - + # Impact on patient patient_impact = models.TextField( - blank=True, - help_text=_("Description of the impact on the patient (physical, emotional, financial)") + blank=True, help_text=_("Description of the impact on the patient (physical, emotional, financial)") ) - + # Verification and handling status = models.CharField( max_length=30, choices=VerificationStatus.choices, default=VerificationStatus.REPORTED, - help_text=_("Current status of the adverse action report") + help_text=_("Current status of the adverse action report"), ) - + reported_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="reported_adverse_actions", - help_text=_("User who reported this adverse action") + help_text=_("User who reported this adverse action"), ) - + # Investigation - investigation_notes = models.TextField( - blank=True, - help_text=_("Notes from the investigation") - ) - + investigation_notes = models.TextField(blank=True, help_text=_("Notes from the investigation")) + investigated_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="investigated_adverse_actions", - help_text=_("User who investigated this adverse action") + help_text=_("User who investigated this adverse action"), ) - - investigated_at = models.DateTimeField( - null=True, - blank=True, - help_text=_("When the investigation was completed") - ) - + + investigated_at = models.DateTimeField(null=True, blank=True, help_text=_("When the investigation was completed")) + # Resolution - resolution = models.TextField( - blank=True, - help_text=_("How the adverse action was resolved") - ) - + resolution = models.TextField(blank=True, help_text=_("How the adverse action was resolved")) + resolved_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_adverse_actions", - help_text=_("User who resolved this adverse action") + help_text=_("User who resolved this adverse action"), ) - - resolved_at = models.DateTimeField( - null=True, - blank=True, - help_text=_("When the adverse action was resolved") - ) - + + resolved_at = models.DateTimeField(null=True, blank=True, help_text=_("When the adverse action was resolved")) + # Metadata is_escalated = models.BooleanField( - default=False, - help_text=_("Whether this adverse action has been escalated to management") + default=False, help_text=_("Whether this adverse action has been escalated to management") ) - - escalated_at = models.DateTimeField( - null=True, - blank=True, - help_text=_("When the adverse action was escalated") - ) - + + escalated_at = models.DateTimeField(null=True, blank=True, help_text=_("When the adverse action was escalated")) + class Meta: ordering = ["-incident_date", "-created_at"] verbose_name = _("Complaint Adverse Action") @@ -2309,23 +2329,24 @@ class ComplaintAdverseAction(UUIDModel, TimeStampedModel): models.Index(fields=["action_type", "severity"]), models.Index(fields=["status", "-created_at"]), ] - + def __str__(self): return f"{self.complaint.reference_number} - {self.get_action_type_display()} ({self.get_severity_display()})" - + @property def is_high_severity(self): """Check if this is a high or critical severity adverse action""" return self.severity in [self.SeverityLevel.HIGH, self.SeverityLevel.CRITICAL] - + @property def days_since_incident(self): """Calculate days since the incident occurred""" from django.utils import timezone + if self.incident_date: return (timezone.now() - self.incident_date).days return None - + @property def requires_investigation(self): """Check if this adverse action requires investigation""" @@ -2336,31 +2357,22 @@ class ComplaintAdverseActionAttachment(UUIDModel, TimeStampedModel): """ Attachments for adverse action reports (evidence, documents, etc.) """ - adverse_action = models.ForeignKey( - ComplaintAdverseAction, - on_delete=models.CASCADE, - related_name="attachments" - ) - + + adverse_action = models.ForeignKey(ComplaintAdverseAction, on_delete=models.CASCADE, related_name="attachments") + file = models.FileField( upload_to="complaints/adverse_actions/%Y/%m/%d/", - help_text=_("Attachment file (image, document, audio recording, etc.)") + help_text=_("Attachment file (image, document, audio recording, etc.)"), ) - + filename = models.CharField(max_length=255) file_type = models.CharField(max_length=100, blank=True) file_size = models.IntegerField(help_text=_("File size in bytes")) - description = models.TextField( - blank=True, - help_text=_("Description of what this attachment shows") - ) + description = models.TextField(blank=True, help_text=_("Description of what this attachment shows")) uploaded_by = models.ForeignKey( - "accounts.User", - on_delete=models.SET_NULL, - null=True, - related_name="adverse_action_attachments" + "accounts.User", on_delete=models.SET_NULL, null=True, related_name="adverse_action_attachments" ) class Meta: @@ -2376,115 +2388,105 @@ class ComplaintAdverseActionAttachment(UUIDModel, TimeStampedModel): # COMPLAINT TEMPLATES # ============================================================================ + class ComplaintTemplate(UUIDModel, TimeStampedModel): """ Pre-defined templates for common complaints. - + Allows quick selection of common complaint types with pre-filled description, category, severity, and auto-assignment. """ - + hospital = models.ForeignKey( - 'organizations.Hospital', + "organizations.Hospital", on_delete=models.CASCADE, - related_name='complaint_templates', - help_text=_("Hospital this template belongs to") + related_name="complaint_templates", + help_text=_("Hospital this template belongs to"), ) - - name = models.CharField( - max_length=200, - help_text=_("Template name (e.g., 'Long Wait Time', 'Rude Staff')") - ) - description = models.TextField( - help_text=_("Default description template with placeholders") - ) - + + name = models.CharField(max_length=200, help_text=_("Template name (e.g., 'Long Wait Time', 'Rude Staff')")) + description = models.TextField(help_text=_("Default description template with placeholders")) + # Pre-set classification category = models.ForeignKey( ComplaintCategory, on_delete=models.SET_NULL, null=True, blank=True, - related_name='templates', - help_text=_("Default category for this template") + related_name="templates", + help_text=_("Default category for this template"), ) - + # Default severity/priority default_severity = models.CharField( max_length=20, choices=SeverityChoices.choices, default=SeverityChoices.MEDIUM, - help_text=_("Default severity level") + help_text=_("Default severity level"), ) default_priority = models.CharField( max_length=20, choices=PriorityChoices.choices, default=PriorityChoices.MEDIUM, - help_text=_("Default priority level") + help_text=_("Default priority level"), ) - + # Auto-assignment auto_assign_department = models.ForeignKey( - 'organizations.Department', + "organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, - related_name='template_assignments', - help_text=_("Auto-assign to this department when template is used") + related_name="template_assignments", + help_text=_("Auto-assign to this department when template is used"), ) - + # Usage tracking usage_count = models.IntegerField( - default=0, - editable=False, - help_text=_("Number of times this template has been used") + default=0, editable=False, help_text=_("Number of times this template has been used") ) - + # Placeholders that can be used in description # e.g., "Patient waited for {{wait_time}} minutes" placeholders = models.JSONField( - default=list, - blank=True, - help_text=_("List of placeholder names used in description") + default=list, blank=True, help_text=_("List of placeholder names used in description") ) - + is_active = models.BooleanField( - default=True, - db_index=True, - help_text=_("Whether this template is available for selection") + default=True, db_index=True, help_text=_("Whether this template is available for selection") ) - + class Meta: - ordering = ['-usage_count', 'name'] - verbose_name = _('Complaint Template') - verbose_name_plural = _('Complaint Templates') - unique_together = [['hospital', 'name']] + ordering = ["-usage_count", "name"] + verbose_name = _("Complaint Template") + verbose_name_plural = _("Complaint Templates") + unique_together = [["hospital", "name"]] indexes = [ - models.Index(fields=['hospital', 'is_active']), - models.Index(fields=['-usage_count']), + models.Index(fields=["hospital", "is_active"]), + models.Index(fields=["-usage_count"]), ] - + def __str__(self): return f"{self.hospital.name} - {self.name} ({self.usage_count} uses)" - + def use_template(self): """Increment usage count""" self.usage_count += 1 - self.save(update_fields=['usage_count']) - + self.save(update_fields=["usage_count"]) + def render_description(self, placeholder_values): """ Render description with placeholder values. - + Args: placeholder_values: Dict of placeholder name -> value - + Returns: Rendered description string """ description = self.description for key, value in placeholder_values.items(): - description = description.replace(f'{{{{{key}}}}}', str(value)) + description = description.replace(f"{{{{{key}}}}}", str(value)) return description @@ -2492,119 +2494,88 @@ class ComplaintTemplate(UUIDModel, TimeStampedModel): # COMMUNICATION LOG # ============================================================================ + class ComplaintCommunicationType(models.TextChoices): """Types of communication""" - PHONE_CALL = 'phone_call', 'Phone Call' - EMAIL = 'email', 'Email' - SMS = 'sms', 'SMS' - MEETING = 'meeting', 'Meeting' - LETTER = 'letter', 'Letter' - OTHER = 'other', 'Other' + + PHONE_CALL = "phone_call", "Phone Call" + EMAIL = "email", "Email" + SMS = "sms", "SMS" + MEETING = "meeting", "Meeting" + LETTER = "letter", "Letter" + OTHER = "other", "Other" class ComplaintCommunication(UUIDModel, TimeStampedModel): """ Tracks all communications related to a complaint. - + Records phone calls, emails, meetings, and other communications with complainants, involved staff, or other stakeholders. """ - + complaint = models.ForeignKey( - Complaint, - on_delete=models.CASCADE, - related_name='communications', - help_text=_("Related complaint") + Complaint, on_delete=models.CASCADE, related_name="communications", help_text=_("Related complaint") ) - + # Communication details communication_type = models.CharField( - max_length=20, - choices=ComplaintCommunicationType.choices, - help_text=_("Type of communication") + max_length=20, choices=ComplaintCommunicationType.choices, help_text=_("Type of communication") ) - + direction = models.CharField( max_length=20, choices=[ - ('inbound', 'Inbound'), - ('outbound', 'Outbound'), + ("inbound", "Inbound"), + ("outbound", "Outbound"), ], - help_text=_("Direction of communication") + help_text=_("Direction of communication"), ) - + # Participants - contacted_person = models.CharField( - max_length=200, - help_text=_("Name of person contacted") - ) + contacted_person = models.CharField(max_length=200, help_text=_("Name of person contacted")) contacted_role = models.CharField( - max_length=100, - blank=True, - help_text=_("Role/relation (e.g., Complainant, Patient, Staff)") + max_length=100, blank=True, help_text=_("Role/relation (e.g., Complainant, Patient, Staff)") ) - contacted_phone = models.CharField( - max_length=20, - blank=True, - help_text=_("Phone number") - ) - contacted_email = models.EmailField( - blank=True, - help_text=_("Email address") - ) - + contacted_phone = models.CharField(max_length=20, blank=True, help_text=_("Phone number")) + contacted_email = models.EmailField(blank=True, help_text=_("Email address")) + # Communication content - subject = models.CharField( - max_length=500, - blank=True, - help_text=_("Subject/summary of communication") - ) - notes = models.TextField( - help_text=_("Details of what was discussed") - ) - + subject = models.CharField(max_length=500, blank=True, help_text=_("Subject/summary of communication")) + notes = models.TextField(help_text=_("Details of what was discussed")) + # Follow-up - requires_followup = models.BooleanField( - default=False, - help_text=_("Whether this communication requires follow-up") - ) - followup_date = models.DateField( - null=True, - blank=True, - help_text=_("Date when follow-up is needed") - ) - followup_notes = models.TextField( - blank=True, - help_text=_("Notes from follow-up") - ) - + requires_followup = models.BooleanField(default=False, help_text=_("Whether this communication requires follow-up")) + followup_date = models.DateField(null=True, blank=True, help_text=_("Date when follow-up is needed")) + followup_notes = models.TextField(blank=True, help_text=_("Notes from follow-up")) + # Attachments (emails, letters, etc.) attachment = models.FileField( - upload_to='complaints/communications/%Y/%m/%d/', + upload_to="complaints/communications/%Y/%m/%d/", null=True, blank=True, - help_text=_("Attached document (email export, letter, etc.)") + help_text=_("Attached document (email export, letter, etc.)"), ) - + # Created by created_by = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, - related_name='complaint_communications', - help_text=_("User who logged this communication") + related_name="complaint_communications", + help_text=_("User who logged this communication"), ) - + class Meta: - ordering = ['-created_at'] - verbose_name = _('Complaint Communication') - verbose_name_plural = _('Complaint Communications') + ordering = ["-created_at"] + verbose_name = _("Complaint Communication") + verbose_name_plural = _("Complaint Communications") indexes = [ - models.Index(fields=['complaint', '-created_at']), - models.Index(fields=['communication_type']), - models.Index(fields=['requires_followup', 'followup_date']), + models.Index(fields=["complaint", "-created_at"]), + models.Index(fields=["communication_type"]), + models.Index(fields=["requires_followup", "followup_date"]), ] - + def __str__(self): return f"{self.complaint.reference_number} - {self.get_communication_type_display()} - {self.contacted_person}" @@ -2613,171 +2584,185 @@ class ComplaintCommunication(UUIDModel, TimeStampedModel): # ROOT CAUSE ANALYSIS (RCA) # ============================================================================ + class RootCauseCategory(models.TextChoices): """Root cause categories for RCA""" - PEOPLE = 'people', 'People (Training, Staffing)' - PROCESS = 'process', 'Process/Procedure' - EQUIPMENT = 'equipment', 'Equipment/Technology' - ENVIRONMENT = 'environment', 'Environment/Facility' - COMMUNICATION = 'communication', 'Communication' - POLICY = 'policy', 'Policy/Protocol' - PATIENT_FACTOR = 'patient_factor', 'Patient-Related Factor' - OTHER = 'other', 'Other' + + PEOPLE = "people", "People (Training, Staffing)" + PROCESS = "process", "Process/Procedure" + EQUIPMENT = "equipment", "Equipment/Technology" + ENVIRONMENT = "environment", "Environment/Facility" + COMMUNICATION = "communication", "Communication" + POLICY = "policy", "Policy/Protocol" + PATIENT_FACTOR = "patient_factor", "Patient-Related Factor" + OTHER = "other", "Other" class ComplaintRootCauseAnalysis(UUIDModel, TimeStampedModel): """ Root Cause Analysis (RCA) for complaints. - + Structured analysis to identify underlying causes and prevent recurrence. Linked to complaints that require formal investigation. """ - + complaint = models.OneToOneField( - Complaint, - on_delete=models.CASCADE, - related_name='root_cause_analysis', - help_text=_("Related complaint") + Complaint, on_delete=models.CASCADE, related_name="root_cause_analysis", help_text=_("Related complaint") ) - + # RCA Team team_leader = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='led_rcas', - help_text=_("RCA team leader") + related_name="led_rcas", + help_text=_("RCA team leader"), ) - team_members = models.TextField( - blank=True, - help_text=_("List of RCA team members (one per line)") - ) - + team_members = models.TextField(blank=True, help_text=_("List of RCA team members (one per line)")) + # Problem statement - problem_statement = models.TextField( - help_text=_("Clear description of what happened") - ) - impact_description = models.TextField( - help_text=_("Impact on patient, organization, etc.") - ) - + problem_statement = models.TextField(help_text=_("Clear description of what happened")) + impact_description = models.TextField(help_text=_("Impact on patient, organization, etc.")) + # Root cause categories (can select multiple) - root_cause_categories = models.JSONField( - default=list, - help_text=_("Selected root cause categories") - ) - + root_cause_categories = models.JSONField(default=list, help_text=_("Selected root cause categories")) + # 5 Whys analysis why_1 = models.TextField(blank=True, help_text=_("Why did this happen? (Level 1)")) why_2 = models.TextField(blank=True, help_text=_("Why? (Level 2)")) why_3 = models.TextField(blank=True, help_text=_("Why? (Level 3)")) why_4 = models.TextField(blank=True, help_text=_("Why? (Level 4)")) why_5 = models.TextField(blank=True, help_text=_("Why? (Level 5)")) - + # Root cause summary - root_cause_summary = models.TextField( - help_text=_("Summary of identified root causes") - ) - + root_cause_summary = models.TextField(help_text=_("Summary of identified root causes")) + # Contributing factors - contributing_factors = models.TextField( - blank=True, - help_text=_("Factors that contributed to the incident") - ) - + contributing_factors = models.TextField(blank=True, help_text=_("Factors that contributed to the incident")) + # Corrective and Preventive Actions (CAPA) - corrective_actions = models.TextField( - help_text=_("Actions to correct the immediate issue") - ) - preventive_actions = models.TextField( - help_text=_("Actions to prevent recurrence") - ) - + corrective_actions = models.TextField(help_text=_("Actions to correct the immediate issue")) + preventive_actions = models.TextField(help_text=_("Actions to prevent recurrence")) + # Action tracking action_owner = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='owned_rca_actions', - help_text=_("Person responsible for implementing actions") - ) - action_due_date = models.DateField( - null=True, - blank=True, - help_text=_("Due date for implementing actions") + related_name="owned_rca_actions", + help_text=_("Person responsible for implementing actions"), ) + action_due_date = models.DateField(null=True, blank=True, help_text=_("Due date for implementing actions")) action_status = models.CharField( max_length=20, choices=[ - ('not_started', 'Not Started'), - ('in_progress', 'In Progress'), - ('completed', 'Completed'), - ('verified', 'Verified Effective'), + ("not_started", "Not Started"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("verified", "Verified Effective"), ], - default='not_started', - help_text=_("Status of corrective actions") + default="not_started", + help_text=_("Status of corrective actions"), ) - + # Effectiveness verification effectiveness_verified = models.BooleanField( - default=False, - help_text=_("Whether the effectiveness of actions has been verified") + default=False, help_text=_("Whether the effectiveness of actions has been verified") ) - effectiveness_date = models.DateField( - null=True, - blank=True, - help_text=_("Date when effectiveness was verified") - ) - effectiveness_notes = models.TextField( - blank=True, - help_text=_("Notes on effectiveness verification") - ) - + effectiveness_date = models.DateField(null=True, blank=True, help_text=_("Date when effectiveness was verified")) + effectiveness_notes = models.TextField(blank=True, help_text=_("Notes on effectiveness verification")) + # RCA Status status = models.CharField( max_length=20, choices=[ - ('draft', 'Draft'), - ('in_review', 'In Review'), - ('approved', 'Approved'), - ('closed', 'Closed'), + ("draft", "Draft"), + ("in_review", "In Review"), + ("approved", "Approved"), + ("closed", "Closed"), ], - default='draft', - help_text=_("RCA status") + default="draft", + help_text=_("RCA status"), ) - + # Approval approved_by = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='approved_rcas', - help_text=_("User who approved the RCA") + related_name="approved_rcas", + help_text=_("User who approved the RCA"), ) - approved_at = models.DateTimeField( - null=True, - blank=True, - help_text=_("Date when RCA was approved") - ) - + approved_at = models.DateTimeField(null=True, blank=True, help_text=_("Date when RCA was approved")) + class Meta: - verbose_name = _('Root Cause Analysis') - verbose_name_plural = _('Root Cause Analyses') + verbose_name = _("Root Cause Analysis") + verbose_name_plural = _("Root Cause Analyses") indexes = [ - models.Index(fields=['action_status', 'action_due_date']), - models.Index(fields=['status']), + models.Index(fields=["action_status", "action_due_date"]), + models.Index(fields=["status"]), ] - + def __str__(self): return f"RCA for {self.complaint.reference_number}" - + @property def is_overdue(self): """Check if action is overdue""" from django.utils import timezone - if self.action_due_date and self.action_status not in ['completed', 'verified']: + + if self.action_due_date and self.action_status not in ["completed", "verified"]: return timezone.now().date() > self.action_due_date return False + + +class PatientComplaintSession(TimeStampedModel): + """ + Token-based session for patients to submit complaints via SMS link. + + Flow: + 1. Staff creates session -> SMS sent to patient with link + 2. Patient opens link -> sees hospital cards grouped by visits + 3. Patient selects hospital -> sees visit list + 4. Patient selects visit -> fills complaint form + """ + + patient = models.ForeignKey("organizations.Patient", on_delete=models.CASCADE, related_name="complaint_sessions") + token = models.CharField(max_length=64, unique=True, db_index=True) + expires_at = models.DateTimeField(db_index=True) + created_by = models.ForeignKey( + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaint_sessions" + ) + is_active = models.BooleanField(default=True, db_index=True) + + def is_expired(self): + from django.utils import timezone + + return timezone.now() > self.expires_at + + def generate_link(self, request=None): + if request: + return request.build_absolute_uri(f"/complaints/patient/{self.token}/") + return f"/complaints/patient/{self.token}/" + + def save(self, *args, **kwargs): + if not self.token: + import secrets + + self.token = secrets.token_urlsafe(32) + if not self.expires_at: + from django.utils import timezone + from django.conf import settings + + days = getattr(settings, "COMPLAINT_LINK_EXPIRY_DAYS", 7) + self.expires_at = timezone.now() + timedelta(days=days) + super().save(*args, **kwargs) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["patient", "is_active"]), + ] diff --git a/apps/complaints/serializers.py b/apps/complaints/serializers.py index 6b27d3b..9040a0f 100644 --- a/apps/complaints/serializers.py +++ b/apps/complaints/serializers.py @@ -1,6 +1,7 @@ """ Complaints serializers """ + from rest_framework import serializers from .models import ( @@ -10,22 +11,31 @@ from .models import ( ComplaintPRInteraction, ComplaintUpdate, Inquiry, - ComplaintExplanation + ComplaintExplanation, ) class ComplaintAttachmentSerializer(serializers.ModelSerializer): """Complaint attachment serializer""" + uploaded_by_name = serializers.SerializerMethodField() class Meta: model = ComplaintAttachment fields = [ - 'id', 'complaint', 'file', 'filename', 'file_type', 'file_size', - 'uploaded_by', 'uploaded_by_name', 'description', - 'created_at', 'updated_at' + "id", + "complaint", + "file", + "filename", + "file_type", + "file_size", + "uploaded_by", + "uploaded_by_name", + "description", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'file_size', 'created_at', 'updated_at'] + read_only_fields = ["id", "file_size", "created_at", "updated_at"] def get_uploaded_by_name(self, obj): """Get uploader name""" @@ -36,17 +46,25 @@ class ComplaintAttachmentSerializer(serializers.ModelSerializer): class ComplaintUpdateSerializer(serializers.ModelSerializer): """Complaint update serializer""" + created_by_name = serializers.SerializerMethodField() class Meta: model = ComplaintUpdate fields = [ - 'id', 'complaint', 'update_type', 'message', - 'created_by', 'created_by_name', - 'old_status', 'new_status', 'metadata', - 'created_at', 'updated_at' + "id", + "complaint", + "update_type", + "message", + "created_by", + "created_by_name", + "old_status", + "new_status", + "metadata", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_at", "updated_at"] def get_created_by_name(self, obj): """Get creator name""" @@ -57,22 +75,32 @@ class ComplaintUpdateSerializer(serializers.ModelSerializer): class ComplaintPRInteractionSerializer(serializers.ModelSerializer): """PR Interaction serializer""" - complaint_title = serializers.CharField(source='complaint.title', read_only=True) + + complaint_title = serializers.CharField(source="complaint.title", read_only=True) pr_staff_name = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField() - contact_method_display = serializers.CharField(source='get_contact_method_display', read_only=True) + contact_method_display = serializers.CharField(source="get_contact_method_display", read_only=True) class Meta: model = ComplaintPRInteraction fields = [ - 'id', 'complaint', 'complaint_title', - 'contact_date', 'contact_method', 'contact_method_display', - 'pr_staff', 'pr_staff_name', - 'statement_text', 'procedure_explained', 'notes', - 'created_by', 'created_by_name', - 'created_at', 'updated_at' + "id", + "complaint", + "complaint_title", + "contact_date", + "contact_method", + "contact_method_display", + "pr_staff", + "pr_staff_name", + "statement_text", + "procedure_explained", + "notes", + "created_by", + "created_by_name", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_at", "updated_at"] def get_pr_staff_name(self, obj): """Get PR staff name""" @@ -89,20 +117,28 @@ class ComplaintPRInteractionSerializer(serializers.ModelSerializer): class ComplaintMeetingSerializer(serializers.ModelSerializer): """Complaint Meeting serializer""" - complaint_title = serializers.CharField(source='complaint.title', read_only=True) + + complaint_title = serializers.CharField(source="complaint.title", read_only=True) created_by_name = serializers.SerializerMethodField() - meeting_type_display = serializers.CharField(source='get_meeting_type_display', read_only=True) + meeting_type_display = serializers.CharField(source="get_meeting_type_display", read_only=True) class Meta: model = ComplaintMeeting fields = [ - 'id', 'complaint', 'complaint_title', - 'meeting_date', 'meeting_type', 'meeting_type_display', - 'outcome', 'notes', - 'created_by', 'created_by_name', - 'created_at', 'updated_at' + "id", + "complaint", + "complaint_title", + "meeting_date", + "meeting_type", + "meeting_type_display", + "outcome", + "notes", + "created_by", + "created_by_name", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_at", "updated_at"] def get_created_by_name(self, obj): """Get creator name""" @@ -113,21 +149,22 @@ class ComplaintMeetingSerializer(serializers.ModelSerializer): class ComplaintSerializer(serializers.ModelSerializer): """Complaint serializer""" - patient_name = serializers.CharField(source='patient.get_full_name', read_only=True) - patient_mrn = serializers.CharField(source='patient.mrn', read_only=True) - hospital_name = serializers.CharField(source='hospital.name', read_only=True) - department_name = serializers.CharField(source='department.name', read_only=True) + + patient_name = serializers.CharField(source="patient.get_full_name", read_only=True) + patient_mrn = serializers.CharField(source="patient.mrn", read_only=True) + hospital_name = serializers.CharField(source="hospital.name", read_only=True) + department_name = serializers.CharField(source="department.name", read_only=True) staff_name = serializers.SerializerMethodField() assigned_to_name = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField() - source_name = serializers.CharField(source='source.name_en', read_only=True) - source_code = serializers.CharField(source='source.code', read_only=True) - complaint_source_type_display = serializers.CharField(source='get_complaint_source_type_display', read_only=True) - complaint_type_display = serializers.CharField(source='get_complaint_type_display', read_only=True) + source_name = serializers.CharField(source="source.name_en", read_only=True) + source_code = serializers.CharField(source="source.code", read_only=True) + complaint_source_type_display = serializers.CharField(source="get_complaint_source_type_display", read_only=True) + complaint_type_display = serializers.CharField(source="get_complaint_type_display", read_only=True) attachments = ComplaintAttachmentSerializer(many=True, read_only=True) updates = ComplaintUpdateSerializer(many=True, read_only=True) sla_status = serializers.SerializerMethodField() - + # 4-level taxonomy fields domain_details = serializers.SerializerMethodField() category_details = serializers.SerializerMethodField() @@ -138,83 +175,126 @@ class ComplaintSerializer(serializers.ModelSerializer): class Meta: model = Complaint fields = [ - 'id', 'patient', 'patient_name', 'patient_mrn', 'encounter_id', - 'hospital', 'hospital_name', 'department', 'department_name', - 'staff', 'staff_name', - 'title', 'description', + "id", + "patient", + "patient_name", + "patient_mrn", + "encounter_id", + "hospital", + "hospital_name", + "department", + "department_name", + "staff", + "staff_name", + "title", + "description", # Reference and tracking - 'reference_number', + "reference_number", # 4-level taxonomy - 'domain', 'domain_details', - 'category', 'category_details', - 'subcategory', 'subcategory_details', - 'subcategory_obj', 'classification', 'classification_obj', 'classification_details', - 'taxonomy_path', - 'priority', 'severity', 'complaint_type', 'complaint_type_display', - 'complaint_source_type', 'complaint_source_type_display', - 'source', 'source_name', 'source_code', 'status', - 'created_by', 'created_by_name', - 'assigned_to', 'assigned_to_name', 'assigned_at', 'activated_at', - 'due_at', 'is_overdue', 'sla_status', - 'reminder_sent_at', 'escalated_at', - 'resolution', 'resolved_at', 'resolved_by', - 'closed_at', 'closed_by', - 'resolution_survey', 'resolution_survey_sent_at', - 'attachments', 'updates', 'metadata', - 'created_at', 'updated_at' + "domain", + "domain_details", + "category", + "category_details", + "subcategory", + "subcategory_details", + "subcategory_obj", + "classification", + "classification_obj", + "classification_details", + "taxonomy_path", + "priority", + "severity", + "complaint_type", + "complaint_type_display", + "complaint_source_type", + "complaint_source_type_display", + "source", + "source_name", + "source_code", + "status", + "created_by", + "created_by_name", + "assigned_to", + "assigned_to_name", + "assigned_at", + "activated_at", + "due_at", + "is_overdue", + "sla_status", + "reminder_sent_at", + "escalated_at", + "resolution", + "resolved_at", + "resolved_by", + "closed_at", + "closed_by", + "resolution_survey", + "resolution_survey_sent_at", + "attachments", + "updates", + "metadata", + "created_at", + "updated_at", ] read_only_fields = [ - 'id', 'created_by', 'assigned_at', 'is_overdue', - 'reference_number', - 'reminder_sent_at', 'escalated_at', - 'resolved_at', 'closed_at', 'resolution_survey_sent_at', - 'created_at', 'updated_at' + "id", + "created_by", + "assigned_at", + "is_overdue", + "reference_number", + "reminder_sent_at", + "escalated_at", + "resolved_at", + "closed_at", + "resolution_survey_sent_at", + "created_at", + "updated_at", ] - + def get_domain_details(self, obj): """Get domain details""" if obj.domain: return { - 'id': str(obj.domain.id), - 'code': obj.domain.code or obj.domain.name_en.upper(), - 'name_en': obj.domain.name_en, - 'name_ar': obj.domain.name_ar + "id": str(obj.domain.id), + "code": obj.domain.code or obj.domain.name_en.upper(), + "name_en": obj.domain.name_en, + "name_ar": obj.domain.name_ar, } return None - + def get_category_details(self, obj): """Get category details""" if obj.category: return { - 'id': str(obj.category.id), - 'code': obj.category.code or obj.category.name_en.upper(), - 'name_en': obj.category.name_en, - 'name_ar': obj.category.name_ar + "id": str(obj.category.id), + "code": obj.category.code or obj.category.name_en.upper(), + "name_en": obj.category.name_en, + "name_ar": obj.category.name_ar, } return None - + def get_subcategory_details(self, obj): """Get subcategory details""" if obj.subcategory_obj: return { - 'id': str(obj.subcategory_obj.id), - 'code': obj.subcategory_obj.code or obj.subcategory_obj.name_en.upper(), - 'name_en': obj.subcategory_obj.name_en, - 'name_ar': obj.subcategory_obj.name_ar + "id": str(obj.subcategory_obj.id), + "code": obj.subcategory_obj.code or obj.subcategory_obj.name_en.upper(), + "name_en": obj.subcategory_obj.name_en, + "name_ar": obj.subcategory_obj.name_ar, } return None - + def get_classification_details(self, obj): """Get classification details""" if obj.classification_obj: return { - 'id': str(obj.classification_obj.id), - 'code': obj.classification_obj.code, - 'name_en': obj.classification_obj.name_en, - 'name_ar': obj.classification_obj.name_ar + "id": str(obj.classification_obj.id), + "code": obj.classification_obj.code, + "name_en": obj.classification_obj.name_en, + "name_ar": obj.classification_obj.name_ar, } return None - + def get_taxonomy_path(self, obj): """Get full taxonomy path as a string""" parts = [] @@ -226,7 +306,7 @@ class ComplaintSerializer(serializers.ModelSerializer): parts.append(obj.subcategory_obj.name_en) if obj.classification_obj: parts.append(obj.classification_obj.name_en) - return ' > '.join(parts) if parts else None + return " > ".join(parts) if parts else None def create(self, validated_data): """ @@ -237,39 +317,36 @@ class ComplaintSerializer(serializers.ModelSerializer): """ from apps.organizations.models import Department import logging + logger = logging.getLogger(__name__) # Extract metadata if present - metadata = validated_data.get('metadata', {}) + metadata = validated_data.get("metadata", {}) # Check if department is not already set and AI analysis contains department info - if not validated_data.get('department') and metadata.get('ai_analysis'): - ai_analysis = metadata['ai_analysis'] - department_name = ai_analysis.get('department') + if not validated_data.get("department") and metadata.get("ai_analysis"): + ai_analysis = metadata["ai_analysis"] + department_name = ai_analysis.get("department") # If AI identified a department, try to match it in the database if department_name: - hospital = validated_data.get('hospital') + hospital = validated_data.get("hospital") if hospital: try: # Try exact match first department = Department.objects.filter( - hospital=hospital, - status='active', - name__iexact=department_name + hospital=hospital, status="active", name__iexact=department_name ).first() # If no exact match, try partial match if not department: department = Department.objects.filter( - hospital=hospital, - status='active', - name__icontains=department_name + hospital=hospital, status="active", name__icontains=department_name ).first() if department: - validated_data['department'] = department + validated_data["department"] = department logger.info( f"Auto-set department '{department.name}' from AI analysis " f"for complaint (matched from '{department_name}')" @@ -305,69 +382,101 @@ class ComplaintSerializer(serializers.ModelSerializer): def get_sla_status(self, obj): """Get SLA status""" - return obj.sla_status if hasattr(obj, 'sla_status') else 'on_track' + return obj.sla_status if hasattr(obj, "sla_status") else "on_track" class ComplaintExplanationSerializer(serializers.ModelSerializer): """Complaint explanation serializer""" + staff_name = serializers.SerializerMethodField() requested_by_name = serializers.SerializerMethodField() attachment_count = serializers.SerializerMethodField() - + class Meta: model = ComplaintExplanation fields = [ - 'id', 'complaint', 'staff', 'staff_name', - 'explanation', 'token', 'is_used', - 'email_sent_at', 'responded_at', - 'submitted_via', 'requested_by', 'requested_by_name', - 'request_message', 'attachment_count', - 'created_at' + "id", + "complaint", + "staff", + "staff_name", + "explanation", + "token", + "is_used", + "email_sent_at", + "responded_at", + "submitted_via", + "requested_by", + "requested_by_name", + "request_message", + "attachment_count", + "created_at", ] - read_only_fields = ['id', 'email_sent_at', 'responded_at', 'created_at'] - + read_only_fields = ["id", "email_sent_at", "responded_at", "created_at"] + def get_staff_name(self, obj): if obj.staff: return f"{obj.staff.first_name} {obj.staff.last_name}" if obj.staff.last_name else "" return "" - + def get_requested_by_name(self, obj): if obj.requested_by: return obj.requested_by.get_full_name() return None - + def get_attachment_count(self, obj): return obj.attachments.count() class ComplaintListSerializer(serializers.ModelSerializer): """Simplified complaint serializer for list views""" - patient_name = serializers.CharField(source='patient.get_full_name', read_only=True) - patient_mrn = serializers.CharField(source='patient.mrn', read_only=True) - hospital_name = serializers.CharField(source='hospital.name', read_only=True) - department_name = serializers.CharField(source='department.name', read_only=True) + + patient_name = serializers.CharField(source="patient.get_full_name", read_only=True) + patient_mrn = serializers.CharField(source="patient.mrn", read_only=True) + hospital_name = serializers.CharField(source="hospital.name", read_only=True) + department_name = serializers.CharField(source="department.name", read_only=True) staff_name = serializers.SerializerMethodField() assigned_to_name = serializers.SerializerMethodField() - source_name = serializers.CharField(source='source.name_en', read_only=True) - complaint_source_type_display = serializers.CharField(source='get_complaint_source_type_display', read_only=True) - complaint_type_display = serializers.CharField(source='get_complaint_type_display', read_only=True) + source_name = serializers.CharField(source="source.name_en", read_only=True) + complaint_source_type_display = serializers.CharField(source="get_complaint_source_type_display", read_only=True) + complaint_type_display = serializers.CharField(source="get_complaint_type_display", read_only=True) sla_status = serializers.SerializerMethodField() taxonomy_summary = serializers.SerializerMethodField() class Meta: model = Complaint fields = [ - 'id', 'reference_number', 'patient_name', 'patient_mrn', 'encounter_id', - 'hospital_name', 'department_name', 'staff_name', - 'title', 'category', 'subcategory', 'taxonomy_summary', - 'priority', 'severity', 'complaint_type', 'complaint_type_display', - 'complaint_source_type', 'complaint_source_type_display', - 'source_name', 'status', - 'assigned_to_name', 'assigned_at', - 'due_at', 'is_overdue', 'sla_status', - 'resolution', 'resolved_at', - 'closed_at', - 'created_at', 'updated_at' + "id", + "reference_number", + "patient_name", + "patient_mrn", + "encounter_id", + "hospital_name", + "department_name", + "staff_name", + "title", + "ai_brief_en", + "ai_brief_ar", + "category", + "subcategory", + "taxonomy_summary", + "priority", + "severity", + "complaint_type", + "complaint_type_display", + "complaint_source_type", + "complaint_source_type_display", + "source_name", + "status", + "assigned_to_name", + "assigned_at", + "due_at", + "is_overdue", + "sla_status", + "resolution", + "resolved_at", + "closed_at", + "created_at", + "updated_at", ] def get_staff_name(self, obj): @@ -381,11 +490,11 @@ class ComplaintListSerializer(serializers.ModelSerializer): if obj.assigned_to: return obj.assigned_to.get_full_name() return None - + def get_sla_status(self, obj): """Get SLA status""" - return obj.sla_status if hasattr(obj, 'sla_status') else 'on_track' - + return obj.sla_status if hasattr(obj, "sla_status") else "on_track" + def get_taxonomy_summary(self, obj): """Get brief taxonomy summary""" parts = [] @@ -393,30 +502,46 @@ class ComplaintListSerializer(serializers.ModelSerializer): parts.append(obj.domain.name_en) if obj.category: parts.append(obj.category.name_en) - return ' > '.join(parts) if parts else None + return " > ".join(parts) if parts else None class InquirySerializer(serializers.ModelSerializer): """Inquiry serializer""" - patient_name = serializers.CharField(source='patient.get_full_name', read_only=True) - hospital_name = serializers.CharField(source='hospital.name', read_only=True) - department_name = serializers.CharField(source='department.name', read_only=True) + + patient_name = serializers.CharField(source="patient.get_full_name", read_only=True) + hospital_name = serializers.CharField(source="hospital.name", read_only=True) + department_name = serializers.CharField(source="department.name", read_only=True) assigned_to_name = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField() class Meta: model = Inquiry fields = [ - 'id', 'patient', 'patient_name', - 'contact_name', 'contact_phone', 'contact_email', - 'hospital', 'hospital_name', 'department', 'department_name', - 'subject', 'message', 'category', 'source', - 'created_by', 'created_by_name', - 'assigned_to', 'assigned_to_name', - 'response', 'responded_at', 'responded_by', - 'created_at', 'updated_at' + "id", + "patient", + "patient_name", + "contact_name", + "contact_phone", + "contact_email", + "hospital", + "hospital_name", + "department", + "department_name", + "subject", + "message", + "category", + "source", + "created_by", + "created_by_name", + "assigned_to", + "assigned_to_name", + "response", + "responded_at", + "responded_by", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_by', 'responded_at', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_by", "responded_at", "created_at", "updated_at"] def get_assigned_to_name(self, obj): """Get assigned user name""" diff --git a/apps/complaints/services/complaint_service.py b/apps/complaints/services/complaint_service.py new file mode 100644 index 0000000..54fc2d7 --- /dev/null +++ b/apps/complaints/services/complaint_service.py @@ -0,0 +1,675 @@ +import logging + +from django.utils import timezone + +from apps.core.services import AuditService +from apps.notifications.services import NotificationService +from apps.organizations.models import Department +from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate + +logger = logging.getLogger(__name__) + + +class ComplaintServiceError(Exception): + pass + + +class ComplaintService: + @staticmethod + def get_escalation_target(complaint, staff=None): + """ + Resolve an escalation target using a fallback chain. + + For explanation escalation (staff provided): + staff.report_to -> staff.department.manager -> + complaint.department.manager -> hospital admins & PX coordinators + + For complaint-level escalation (no staff): + complaint.department.manager -> hospital admins & PX coordinators + + Args: + complaint: Complaint instance + staff: Optional Staff instance (the person being escalated from) + + Returns: + tuple: (target_user, fallback_path) where target_user is a User + (or None if no target found) and fallback_path describes + which step succeeded. + """ + from apps.complaints.tasks import get_hospital_admins_and_coordinators + + if staff: + if staff.report_to and staff.report_to.user and staff.report_to.user.is_active: + return staff.report_to.user, "staff.report_to" + + staff_dept = getattr(staff, "department", None) + if staff_dept and staff_dept.manager and staff_dept.manager.is_active: + return staff_dept.manager, "staff.department.manager" + + if complaint.department and complaint.department.manager and complaint.department.manager.is_active: + if not staff or (staff and getattr(staff, "department", None) != complaint.department): + return complaint.department.manager, "complaint.department.manager" + + hospital = complaint.hospital + if hospital: + fallback = get_hospital_admins_and_coordinators(hospital).first() + if fallback: + return fallback, "hospital_admins_coordinators" + + return None, "no_target_found" + + @staticmethod + def can_manage(user, complaint): + if user.is_px_admin(): + return True + if user.is_hospital_admin() and user.hospital == complaint.hospital: + return True + if user.is_department_manager() and user.department == complaint.department: + return True + if complaint.assigned_to and complaint.assigned_to == user: + return True + if complaint.involved_departments.filter(id=user.department_id).exists() if user.department_id else False: + return True + return False + + @staticmethod + def can_activate(user, complaint): + return ( + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and complaint.department == user.department) + or complaint.hospital == user.hospital + ) + + @staticmethod + def activate(complaint, user, request=None): + if not complaint.is_active_status: + raise ComplaintServiceError( + f"Cannot activate complaint with status '{complaint.get_status_display()}'. " + "Complaint must be Open, In Progress, or Partially Resolved." + ) + + if not ComplaintService.can_activate(user, complaint): + raise ComplaintServiceError("You don't have permission to activate this complaint.") + + if complaint.assigned_to == user: + raise ComplaintServiceError("This complaint is already assigned to you.") + + previous_assignee = complaint.assigned_to + old_status = complaint.status + + complaint.assigned_to = user + complaint.assigned_at = timezone.now() + + if complaint.status == ComplaintStatus.OPEN: + complaint.status = ComplaintStatus.IN_PROGRESS + complaint.activated_at = timezone.now() + complaint.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"]) + else: + complaint.save(update_fields=["assigned_to", "assigned_at"]) + + assign_message = f"Complaint activated and assigned to {user.get_full_name()}" + if previous_assignee: + assign_message += f" (reassigned from {previous_assignee.get_full_name()})" + + roles_display = ", ".join(user.get_role_names()) + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="assignment", + message=f"{assign_message} ({roles_display})", + created_by=user, + metadata={ + "old_assignee_id": str(previous_assignee.id) if previous_assignee else None, + "new_assignee_id": str(user.id), + "assignee_roles": user.get_role_names(), + "old_status": old_status, + "new_status": complaint.status, + "activated_by_current_user": True, + }, + ) + + metadata = { + "old_assignee_id": str(previous_assignee.id) if previous_assignee else None, + "new_assignee_id": str(user.id), + "old_status": old_status, + "new_status": complaint.status, + } + + if request: + AuditService.log_from_request( + event_type="complaint_activated", + description=f"Complaint activated by {user.get_full_name()}", + request=request, + content_object=complaint, + metadata=metadata, + ) + else: + AuditService.log_event( + event_type="complaint_activated", + description=f"Complaint activated by {user.get_full_name()}", + user=user, + content_object=complaint, + metadata=metadata, + ) + + return { + "success": True, + "complaint": complaint, + "old_assignee": previous_assignee, + "old_status": old_status, + } + + @staticmethod + def assign(complaint, target_user, assigned_by, request=None): + if not complaint.is_active_status: + raise ComplaintServiceError( + f"Cannot assign complaint with status '{complaint.get_status_display()}'. " + "Complaint must be Open, In Progress, or Partially Resolved." + ) + + if not (assigned_by.is_px_admin() or assigned_by.is_hospital_admin()): + raise ComplaintServiceError("You don't have permission to assign complaints.") + + old_assignee = complaint.assigned_to + + complaint.assigned_to = target_user + complaint.assigned_at = timezone.now() + complaint.save(update_fields=["assigned_to", "assigned_at"]) + + roles_display = ", ".join(target_user.get_role_names()) + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="assignment", + message=f"Assigned to {target_user.get_full_name()} ({roles_display})", + created_by=assigned_by, + metadata={ + "old_assignee_id": str(old_assignee.id) if old_assignee else None, + "new_assignee_id": str(target_user.id), + "assignee_roles": target_user.get_role_names(), + }, + ) + + metadata = { + "old_assignee_id": str(old_assignee.id) if old_assignee else None, + "new_assignee_id": str(target_user.id), + } + + if request: + AuditService.log_from_request( + event_type="assignment", + description=f"Complaint assigned to {target_user.get_full_name()} ({roles_display})", + request=request, + content_object=complaint, + metadata=metadata, + ) + else: + AuditService.log_event( + event_type="assignment", + description=f"Complaint assigned to {target_user.get_full_name()}", + user=assigned_by, + content_object=complaint, + metadata=metadata, + ) + + return { + "success": True, + "complaint": complaint, + "old_assignee": old_assignee, + } + + @staticmethod + def change_status( + complaint, + new_status, + changed_by, + request=None, + *, + note="", + resolution="", + resolution_outcome="", + resolution_outcome_other="", + resolution_category="", + ): + if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()): + raise ComplaintServiceError("You don't have permission to change complaint status.") + + if not new_status: + raise ComplaintServiceError("Please select a status.") + + old_status = complaint.status + complaint.status = new_status + + if new_status == ComplaintStatus.RESOLVED or new_status == "resolved": + complaint.resolved_at = timezone.now() + complaint.resolved_by = changed_by + if resolution: + complaint.resolution = resolution + complaint.resolution_sent_at = timezone.now() + if resolution_category: + complaint.resolution_category = resolution_category + if resolution_outcome: + complaint.resolution_outcome = resolution_outcome + if resolution_outcome == "other" and resolution_outcome_other: + complaint.resolution_outcome_other = resolution_outcome_other + + elif new_status == ComplaintStatus.CLOSED or new_status == "closed": + complaint.closed_at = timezone.now() + complaint.closed_by = changed_by + from apps.complaints.tasks import send_complaint_resolution_survey + + send_complaint_resolution_survey.delay(str(complaint.id)) + + complaint.save() + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="status_change", + message=note or f"Status changed from {old_status} to {new_status}", + created_by=changed_by, + old_status=old_status, + new_status=new_status, + metadata={ + "resolution_text": resolution if resolution else None, + "resolution_category": resolution_category if resolution_category else None, + }, + ) + + metadata = { + "old_status": old_status, + "new_status": new_status, + "resolution_category": resolution_category if resolution_category else None, + } + + if request: + AuditService.log_from_request( + event_type="status_change", + description=f"Complaint status changed from {old_status} to {new_status}", + request=request, + content_object=complaint, + metadata=metadata, + ) + else: + AuditService.log_event( + event_type="status_change", + description=f"Complaint status changed from {old_status} to {new_status}", + user=changed_by, + content_object=complaint, + metadata=metadata, + ) + + return { + "success": True, + "complaint": complaint, + "old_status": old_status, + "new_status": new_status, + } + + @staticmethod + def add_note(complaint, message, created_by, request=None): + if not complaint.is_active_status: + raise ComplaintServiceError( + f"Cannot add notes to complaint with status '{complaint.get_status_display()}'. " + "Complaint must be Open, In Progress, or Partially Resolved." + ) + + if not message: + raise ComplaintServiceError("Please enter a note.") + + update = ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=message, + created_by=created_by, + ) + + metadata = { + "note_id": str(update.id), + } + + if request: + AuditService.log_from_request( + event_type="note_added", + description=f"Note added to complaint", + request=request, + content_object=complaint, + metadata=metadata, + ) + else: + AuditService.log_event( + event_type="note_added", + description=f"Note added to complaint", + user=created_by, + content_object=complaint, + metadata=metadata, + ) + + return update + + @staticmethod + def change_department(complaint, department, changed_by, request=None): + if not complaint.is_active_status: + raise ComplaintServiceError( + f"Cannot change department for complaint with status '{complaint.get_status_display()}'. " + "Complaint must be Open, In Progress, or Partially Resolved." + ) + + if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()): + raise ComplaintServiceError("You don't have permission to change complaint department.") + + if department.hospital != complaint.hospital: + raise ComplaintServiceError("Department does not belong to this complaint's hospital.") + + old_department = complaint.department + complaint.department = department + complaint.save(update_fields=["department"]) + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="assignment", + message=f"Department changed to {department.name}", + created_by=changed_by, + metadata={ + "old_department_id": str(old_department.id) if old_department else None, + "new_department_id": str(department.id), + }, + ) + + metadata = { + "old_department_id": str(old_department.id) if old_department else None, + "new_department_id": str(department.id), + } + + if request: + AuditService.log_from_request( + event_type="department_change", + description=f"Complaint department changed to {department.name}", + request=request, + content_object=complaint, + metadata=metadata, + ) + else: + AuditService.log_event( + event_type="department_change", + description=f"Complaint department changed to {department.name}", + user=changed_by, + content_object=complaint, + metadata=metadata, + ) + + return { + "success": True, + "complaint": complaint, + "old_department": old_department, + } + + @staticmethod + def request_explanation( + complaint, + staff_list, + selected_staff_ids, + selected_manager_ids, + request_message, + requested_by, + domain, + request=None, + ): + import secrets + + if not complaint.is_active_status: + raise ComplaintServiceError( + f"Cannot request explanation for complaint with status '{complaint.get_status_display()}'. " + "Complaint must be Open, In Progress, or Partially Resolved." + ) + + staff_count = 0 + manager_count = 0 + skipped_no_email = 0 + results = [] + notified_managers = set() + + for recipient in staff_list: + staff = recipient["staff"] + staff_id = recipient["staff_id"] + + if staff_id not in selected_staff_ids: + continue + + staff_email = recipient.get("staff_email") + if not staff_email: + skipped_no_email += 1 + continue + + staff_token = secrets.token_urlsafe(32) + + explanation, created = ComplaintExplanation.objects.update_or_create( + complaint=complaint, + staff=staff, + defaults={ + "token": staff_token, + "is_used": False, + "requested_by": requested_by, + "request_message": request_message, + "email_sent_at": timezone.now(), + "submitted_via": "email_link", + }, + ) + + staff_link = f"https://{domain}/complaints/{complaint.id}/explain/{staff_token}/" + staff_subject = f"Explanation Request - Complaint #{complaint.reference_number}" + staff_display = recipient.get("staff_name", str(staff)) + + staff_email_body = f"""Dear {staff_display}, + +We have received a complaint that requires your explanation. + +COMPLAINT DETAILS: +---------------- +Reference: {complaint.reference_number} +Title: {complaint.title} +Severity: {complaint.get_severity_display()} +Priority: {complaint.get_priority_display()} + +{complaint.description or "No description provided."}""" + + if complaint.patient: + staff_email_body += f""" + +PATIENT INFORMATION: +------------------ +Name: {complaint.patient.get_full_name()} +MRN: {complaint.patient.mrn or "N/A"}""" + + if request_message: + staff_email_body += f""" + +ADDITIONAL MESSAGE: +------------------ +{request_message}""" + + staff_email_body += f""" + +SUBMIT YOUR EXPLANATION: +------------------------ +Please submit your explanation about this complaint: +{staff_link} + +Note: This link can only be used once. After submission, it will expire. + +If you have any questions, please contact the PX team. + +--- +This is an automated message from PX360 Complaint Management System.""" + + try: + NotificationService.send_email( + email=staff_email, + subject=staff_subject, + message=staff_email_body, + related_object=complaint, + metadata={ + "notification_type": "explanation_request", + "staff_id": str(staff.id), + "complaint_id": str(complaint.id), + }, + ) + staff_count += 1 + results.append( + { + "recipient_type": "staff", + "recipient": staff_display, + "email": staff_email, + "explanation_id": str(explanation.id), + "sent": True, + } + ) + except Exception as e: + logger.error(f"Failed to send explanation request to staff {staff.id}: {e}") + results.append( + { + "recipient_type": "staff", + "recipient": staff_display, + "email": staff_email, + "explanation_id": str(explanation.id), + "sent": False, + "error": str(e), + } + ) + + manager = recipient.get("manager") + manager_id = recipient.get("manager_id") + if manager and manager_id and manager_id in selected_manager_ids: + if manager.id not in notified_managers: + manager_email = recipient.get("manager_email") + if manager_email: + manager_display = recipient.get("manager_name", str(manager)) + manager_subject = f"Staff Explanation Requested - Complaint #{complaint.reference_number}" + + manager_email_body = f"""Dear {manager_display}, + +This is an informational notification that an explanation has been requested from a staff member who reports to you. + +STAFF MEMBER: +------------ +Name: {staff_display} +Department: {recipient.get("department", "")} +Role in Complaint: {recipient.get("role", "")} + +COMPLAINT DETAILS: +---------------- +Reference: {complaint.reference_number} +Title: {complaint.title} +Severity: {complaint.get_severity_display()} + +The staff member has been sent a link to submit their explanation. You will be notified when they respond. + +If you have any questions, please contact the PX team. + +--- +This is an automated message from PX360 Complaint Management System.""" + + try: + NotificationService.send_email( + email=manager_email, + subject=manager_subject, + message=manager_email_body, + related_object=complaint, + metadata={ + "notification_type": "explanation_request_manager_notification", + "manager_id": str(manager.id), + "staff_id": str(staff.id), + "complaint_id": str(complaint.id), + }, + ) + manager_count += 1 + notified_managers.add(manager.id) + except Exception as e: + logger.error(f"Failed to send manager notification to {manager.id}: {e}") + + metadata = { + "staff_count": staff_count, + "manager_count": manager_count, + "skipped_no_email": skipped_no_email, + "selected_staff_ids": selected_staff_ids, + "selected_manager_ids": selected_manager_ids, + } + + if request: + AuditService.log_from_request( + event_type="explanation_requested", + description=f"Explanation requests sent to {staff_count} staff and {manager_count} managers", + request=request, + content_object=complaint, + metadata=metadata, + ) + else: + AuditService.log_event( + event_type="explanation_requested", + description=f"Explanation requests sent to {staff_count} staff and {manager_count} managers", + user=requested_by, + content_object=complaint, + metadata=metadata, + ) + + if staff_count > 0: + recipients_str = ", ".join([r["recipient"] for r in results if r["sent"]]) + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="communication", + message=f"Explanation request sent to: {recipients_str}", + created_by=requested_by, + metadata={ + "staff_count": staff_count, + "manager_count": manager_count, + "results": results, + }, + ) + complaint.status = ComplaintStatus.CONTACTED + complaint.save(update_fields=["status", "updated_at"]) + + return { + "staff_count": staff_count, + "manager_count": manager_count, + "skipped_no_email": skipped_no_email, + "results": results, + } + + @staticmethod + def post_create_hooks(complaint, created_by, request=None): + from apps.complaints.tasks import analyze_complaint_with_ai, notify_admins_new_complaint + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message="Complaint created. AI analysis running in background.", + created_by=created_by, + ) + + analyze_complaint_with_ai.delay(str(complaint.id)) + notify_admins_new_complaint.delay(str(complaint.id)) + + metadata = { + "severity": complaint.severity, + "patient_name": complaint.patient_name, + "national_id": complaint.national_id, + "hospital": complaint.hospital.name if complaint.hospital else None, + "ai_analysis_pending": True, + } + + if request: + AuditService.log_from_request( + event_type="complaint_created", + description=f"Complaint created: {complaint.title}", + request=request, + content_object=complaint, + metadata=metadata, + ) + else: + AuditService.log_event( + event_type="complaint_created", + description=f"Complaint created: {complaint.title}", + user=created_by, + content_object=complaint, + metadata=metadata, + ) diff --git a/apps/complaints/tasks.py b/apps/complaints/tasks.py index 797ed62..f9d261e 100644 --- a/apps/complaints/tasks.py +++ b/apps/complaints/tasks.py @@ -737,6 +737,16 @@ def escalate_complaint_auto(complaint_id): if complaint.department and complaint.department.manager: escalation_target = complaint.department.manager + if not escalation_target: + from apps.complaints.services.complaint_service import ComplaintService + + escalation_target, fallback_path = ComplaintService.get_escalation_target(complaint) + if escalation_target: + logger.info( + f"Department manager not found for complaint {complaint_id}, " + f"using fallback via {fallback_path}: {escalation_target.get_full_name()}" + ) + elif matching_rule.escalate_to_role == "hospital_admin": # Find hospital admin for this hospital escalation_target = User.objects.filter( @@ -1082,6 +1092,11 @@ def analyze_complaint_with_ai(complaint_id): elif analysis.get("title"): complaint.title = analysis["title"] + if analysis.get("brief_summary_en"): + complaint.ai_brief_en = analysis["brief_summary_en"] + if analysis.get("brief_summary_ar"): + complaint.ai_brief_ar = analysis["brief_summary_ar"] + # Get ALL staff names from analyze_complaint result (extracted by AI) staff_names = analysis.get("staff_names", []) primary_staff_name = analysis.get("primary_staff_name", "").strip() @@ -1242,6 +1257,8 @@ def analyze_complaint_with_ai(complaint_id): "complaint_type": complaint_type, "title_en": analysis.get("title_en", ""), "title_ar": analysis.get("title_ar", ""), + "brief_summary_en": analysis.get("brief_summary_en", ""), + "brief_summary_ar": analysis.get("brief_summary_ar", ""), "short_description_en": analysis.get("short_description_en", ""), "short_description_ar": analysis.get("short_description_ar", ""), # Store suggested actions as list (new format) @@ -1362,6 +1379,8 @@ def analyze_complaint_with_ai(complaint_id): "department": department_name, "title_en": analysis.get("title_en", ""), "title_ar": analysis.get("title_ar", ""), + "brief_summary_en": analysis.get("brief_summary_en", ""), + "brief_summary_ar": analysis.get("brief_summary_ar", ""), "short_description_en": analysis.get("short_description_en", ""), "short_description_ar": analysis.get("short_description_ar", ""), "suggested_action_en": analysis.get("suggested_action_en", ""), @@ -1435,6 +1454,11 @@ def send_complaint_notification(complaint_id, event_type): if complaint.department and complaint.department.manager: recipients.append(complaint.department.manager) + # Fallback: notify hospital admins/coordinators if no recipients found + if not recipients and complaint.hospital: + fallback = get_hospital_admins_and_coordinators(complaint.hospital) + recipients = list(fallback[:3]) + elif event_type == "resolved": # Notify patient recipients.append(complaint.patient) @@ -1653,89 +1677,128 @@ def check_overdue_explanation_requests(): ) continue - # Determine escalation target - manager of the staff member - if explanation.staff and explanation.staff.report_to: - manager = explanation.staff.report_to + # Ensure reminders have been sent before escalating + reminders_required = 2 if (sla_config and sla_config.second_reminder_enabled) else 1 - # Check if manager already has an active explanation request for this complaint - existing_manager_explanation = ComplaintExplanation.objects.filter( - complaint=explanation.complaint, staff=manager - ).first() - - if existing_manager_explanation and not existing_manager_explanation.is_used: - logger.info( - f"Manager {manager.get_full_name()} already has an active explanation " - f"request for complaint {explanation.complaint.id}, skipping escalation" - ) - # Mark as escalated anyway to avoid repeated checks - explanation.escalated_to_manager = existing_manager_explanation - explanation.escalated_at = now - explanation.metadata["escalation_level"] = current_level + 1 - explanation.save(update_fields=["escalated_to_manager", "escalated_at", "metadata"]) - escalated_count += 1 - continue - - if existing_manager_explanation and existing_manager_explanation.is_used: - logger.info( - f"Manager {manager.get_full_name()} already submitted an explanation " - f"for complaint {explanation.complaint.id}, skipping escalation" - ) - # Mark as escalated - explanation.escalated_to_manager = existing_manager_explanation - explanation.escalated_at = now - explanation.metadata["escalation_level"] = current_level + 1 - explanation.save(update_fields=["escalated_to_manager", "escalated_at", "metadata"]) - escalated_count += 1 - continue - - # Create new explanation request for manager with token/link - import secrets - - manager_token = secrets.token_urlsafe(32) - - # Calculate new SLA deadline for manager - sla_hours = sla_config.response_hours if sla_config else 48 - - new_explanation = ComplaintExplanation.objects.create( - complaint=explanation.complaint, - staff=manager, - token=manager_token, - explanation="", # Will be filled by manager - requested_by=explanation.requested_by, - request_message=( - f"ESCALATED: {explanation.staff.get_full_name()} did not provide an explanation " - f"within the SLA deadline ({sla_hours} hours). " - f"As their manager, please provide your explanation about this complaint." - ), - submitted_via="email_link", - sla_due_at=now + timezone.timedelta(hours=sla_hours), - email_sent_at=now, - metadata={ - "escalated_from_explanation_id": str(explanation.id), - "escalation_level": current_level + 1, - "original_staff_id": str(explanation.staff.id), - "original_staff_name": explanation.staff.get_full_name(), - "is_escalation": True, - }, + if reminders_required >= 1 and not explanation.reminder_sent_at: + logger.info( + f"Explanation {explanation.id} overdue but first reminder not sent yet, " + f"skipping escalation (reminders required: {reminders_required})" ) + continue - # Link old explanation to new one - explanation.escalated_to_manager = new_explanation + if reminders_required >= 2 and not explanation.second_reminder_sent_at: + logger.info( + f"Explanation {explanation.id} overdue but second reminder not sent yet, " + f"skipping escalation (reminders required: {reminders_required})" + ) + continue + + # Determine escalation target via fallback chain + from apps.complaints.services.complaint_service import ComplaintService + + target_user, fallback_path = ComplaintService.get_escalation_target( + explanation.complaint, staff=explanation.staff + ) + + if not target_user: + logger.warning( + f"No escalation target found for explanation {explanation.id} " + f"(staff: {explanation.staff}, fallback_path: {fallback_path})" + ) + continue + + # Find or create Staff record for the target user + from apps.organizations.models import Staff + + manager = Staff.objects.filter(user=target_user).first() + + if not manager: + logger.warning( + f"Escalation target user {target_user.id} has no Staff record, " + f"cannot create explanation request for complaint {explanation.complaint.id}" + ) + continue + + # Check if manager already has an active explanation request for this complaint + existing_manager_explanation = ComplaintExplanation.objects.filter( + complaint=explanation.complaint, staff=manager + ).first() + + if existing_manager_explanation and not existing_manager_explanation.is_used: + logger.info( + f"Manager {manager.get_full_name()} already has an active explanation " + f"request for complaint {explanation.complaint.id}, skipping escalation" + ) + explanation.escalated_to_manager = existing_manager_explanation explanation.escalated_at = now explanation.metadata["escalation_level"] = current_level + 1 + explanation.metadata["escalation_fallback_path"] = fallback_path explanation.save(update_fields=["escalated_to_manager", "escalated_at", "metadata"]) - - # Send email to manager with link - send_explanation_request_email.delay(str(new_explanation.id)) - escalated_count += 1 + continue + if existing_manager_explanation and existing_manager_explanation.is_used: logger.info( - f"Escalated explanation request {explanation.id} to manager " - f"{manager.get_full_name()} (Level {current_level + 1})" + f"Manager {manager.get_full_name()} already submitted an explanation " + f"for complaint {explanation.complaint.id}, skipping escalation" ) - else: - logger.warning(f"No escalation target for explanation {explanation.id} (staff has no report_to manager)") + explanation.escalated_to_manager = existing_manager_explanation + explanation.escalated_at = now + explanation.metadata["escalation_level"] = current_level + 1 + explanation.metadata["escalation_fallback_path"] = fallback_path + explanation.save(update_fields=["escalated_to_manager", "escalated_at", "metadata"]) + escalated_count += 1 + continue + + # Create new explanation request for manager with token/link + import secrets + + manager_token = secrets.token_urlsafe(32) + + # Calculate new SLA deadline for manager + sla_hours = sla_config.response_hours if sla_config else 48 + + new_explanation = ComplaintExplanation.objects.create( + complaint=explanation.complaint, + staff=manager, + token=manager_token, + explanation="", + requested_by=explanation.requested_by, + request_message=( + f"ESCALATED: {explanation.staff.get_full_name()} did not provide an explanation " + f"within the SLA deadline ({sla_hours} hours). " + f"As their manager, please provide your explanation about this complaint." + ), + submitted_via="email_link", + sla_due_at=now + timezone.timedelta(hours=sla_hours), + email_sent_at=now, + metadata={ + "escalated_from_explanation_id": str(explanation.id), + "escalation_level": current_level + 1, + "original_staff_id": str(explanation.staff.id), + "original_staff_name": explanation.staff.get_full_name(), + "is_escalation": True, + "escalation_fallback_path": fallback_path, + }, + ) + + # Link old explanation to new one + explanation.escalated_to_manager = new_explanation + explanation.escalated_at = now + explanation.metadata["escalation_level"] = current_level + 1 + explanation.metadata["escalation_fallback_path"] = fallback_path + explanation.save(update_fields=["escalated_to_manager", "escalated_at", "metadata"]) + + # Send email to manager with link + send_explanation_request_email.delay(str(new_explanation.id)) + + escalated_count += 1 + + logger.info( + f"Escalated explanation request {explanation.id} to {target_user.get_full_name()} " + f"(Level {current_level + 1}, path: {fallback_path})" + ) return {"overdue_count": overdue_explanations.count(), "escalated_count": escalated_count} @@ -1746,7 +1809,7 @@ def send_explanation_reminders(): Send reminder emails for explanation requests approaching deadline. Runs every hour via Celery Beat. - Sends reminder to staff if explanation not submitted and deadline approaching. + Sends first and second reminders to staff if explanation not submitted and deadline approaching. """ from apps.complaints.models import ComplaintExplanation from django.core.mail import send_mail @@ -1755,34 +1818,26 @@ def send_explanation_reminders(): now = timezone.now() - # Get explanation requests that: - # - Not submitted (is_used=False) - # - Email sent (email_sent_at is not null) - # - Haven't been reminded yet - # - Approaching deadline + # First reminders: not yet reminded explanations = ComplaintExplanation.objects.filter( is_used=False, email_sent_at__isnull=False, reminder_sent_at__isnull=True, escalated_to_manager__isnull=True ).select_related("complaint", "staff") reminder_count = 0 + second_reminder_count = 0 for explanation in explanations: - # Get SLA config sla_config = get_explanation_sla_config(explanation.complaint.hospital) reminder_hours_before = sla_config.reminder_hours_before if sla_config else 12 - # Calculate reminder threshold time reminder_time = explanation.sla_due_at - timezone.timedelta(hours=reminder_hours_before) - # Check if we should send reminder now if now >= reminder_time: - # Calculate hours remaining hours_remaining = (explanation.sla_due_at - now).total_seconds() / 3600 if hours_remaining < 0: - continue # Already overdue, will be handled by check_overdue_explanation_requests + continue - # Prepare email context context = { "explanation": explanation, "complaint": explanation.complaint, @@ -1795,11 +1850,9 @@ def send_explanation_reminders(): subject = f"Reminder: Explanation Request - Complaint #{str(explanation.complaint.id)[:8]}" try: - # Render email templates message_en = render_to_string("complaints/emails/explanation_reminder_en.txt", context) message_ar = render_to_string("complaints/emails/explanation_reminder_ar.txt", context) - # Send email send_mail( subject=subject, message=f"{message_en}\n\n{message_ar}", @@ -1808,14 +1861,13 @@ def send_explanation_reminders(): fail_silently=False, ) - # Update explanation explanation.reminder_sent_at = now explanation.save(update_fields=["reminder_sent_at"]) reminder_count += 1 logger.info( - f"Explanation reminder sent to {explanation.staff.get_full_name()} " + f"First explanation reminder sent to {explanation.staff.get_full_name()} " f"for complaint {explanation.complaint_id} " f"({int(hours_remaining)} hours remaining)" ) @@ -1823,7 +1875,69 @@ def send_explanation_reminders(): except Exception as e: logger.error(f"Failed to send explanation reminder for {explanation.id}: {str(e)}") - return {"status": "completed", "reminders_sent": reminder_count} + # Second reminders: first reminder sent, second not yet sent + second_reminder_explanations = ComplaintExplanation.objects.filter( + is_used=False, + email_sent_at__isnull=False, + reminder_sent_at__isnull=False, + second_reminder_sent_at__isnull=True, + escalated_to_manager__isnull=True, + ).select_related("complaint", "staff") + + for explanation in second_reminder_explanations: + sla_config = get_explanation_sla_config(explanation.complaint.hospital) + + if not sla_config or not sla_config.second_reminder_enabled: + continue + + second_reminder_hours_before = sla_config.second_reminder_hours_before + + second_reminder_time = explanation.sla_due_at - timezone.timedelta(hours=second_reminder_hours_before) + + if now >= second_reminder_time: + hours_remaining = (explanation.sla_due_at - now).total_seconds() / 3600 + + if hours_remaining < 0: + continue + + context = { + "explanation": explanation, + "complaint": explanation.complaint, + "staff": explanation.staff, + "hours_remaining": int(hours_remaining), + "due_date": explanation.sla_due_at, + "site_url": settings.SITE_URL if hasattr(settings, "SITE_URL") else "http://localhost:8000", + } + + subject = f"URGENT - Final Reminder: Explanation Request - Complaint #{str(explanation.complaint.id)[:8]}" + + try: + message_en = render_to_string("complaints/emails/explanation_second_reminder_en.txt", context) + message_ar = render_to_string("complaints/emails/explanation_second_reminder_ar.txt", context) + + send_mail( + subject=subject, + message=f"{message_en}\n\n{message_ar}", + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[explanation.staff.email], + fail_silently=False, + ) + + explanation.second_reminder_sent_at = now + explanation.save(update_fields=["second_reminder_sent_at"]) + + second_reminder_count += 1 + + logger.info( + f"Second explanation reminder sent to {explanation.staff.get_full_name()} " + f"for complaint {explanation.complaint_id} " + f"({int(hours_remaining)} hours remaining)" + ) + + except Exception as e: + logger.error(f"Failed to send second explanation reminder for {explanation.id}: {str(e)}") + + return {"status": "completed", "reminders_sent": reminder_count, "second_reminders_sent": second_reminder_count} def get_hospital_admins_and_coordinators(hospital): @@ -2572,3 +2686,108 @@ This is an automated notification from the PX 360 system. error_msg = f"Error notifying admins for complaint {complaint_id}: {str(e)}" logger.error(error_msg, exc_info=True) return {"status": "error", "reason": error_msg} + + +@shared_task +def check_overdue_inquiries(): + """ + Periodic task to check for overdue inquiries. + + Runs every 15 minutes (configured in config/celery.py). + Updates is_overdue flag and sets breached_at for inquiries past their SLA deadline. + """ + from apps.complaints.models import Inquiry + + active_statuses = ["open", "in_progress"] + active_inquiries = Inquiry.objects.filter( + status__in=active_statuses, + due_at__isnull=False, + ).select_related("hospital", "department") + + overdue_count = 0 + + for inquiry in active_inquiries: + if inquiry.check_overdue(): + overdue_count += 1 + logger.warning(f"Inquiry {inquiry.id} is overdue: {inquiry.subject} (due: {inquiry.due_at})") + + if overdue_count > 0: + logger.info(f"Found {overdue_count} overdue inquiries") + + return {"overdue_count": overdue_count} + + +@shared_task +def send_inquiry_sla_reminders(): + """ + Periodic task to send SLA reminder emails for inquiries approaching deadline. + + Runs every hour (configured in config/celery.py). + """ + from apps.complaints.models import Inquiry, InquirySLAConfig + from apps.notifications.services import NotificationService + + now = timezone.now() + active_statuses = ["open", "in_progress"] + + active_inquiries = Inquiry.objects.filter( + status__in=active_statuses, + due_at__isnull=False, + is_overdue=False, + ).select_related("hospital", "assigned_to") + + first_reminder_count = 0 + second_reminder_count = 0 + + for inquiry in active_inquiries: + config = inquiry.get_sla_config() + if not config: + continue + + first_reminder_hours = config.get_first_reminder_hours_after() + second_reminder_hours = config.get_second_reminder_hours_after() + + hours_until_due = (inquiry.due_at - now).total_seconds() / 3600 + + if first_reminder_hours > 0 and inquiry.reminder_sent_at is None: + hours_since_creation = (now - inquiry.created_at).total_seconds() / 3600 + if hours_since_creation >= first_reminder_hours: + if inquiry.assigned_to and inquiry.assigned_to.email: + try: + NotificationService.send_email( + email=inquiry.assigned_to.email, + subject=f"SLA Reminder - Inquiry #{inquiry.id}", + message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. Please take action.", + related_object=inquiry, + ) + inquiry.reminder_sent_at = now + inquiry.save(update_fields=["reminder_sent_at"]) + first_reminder_count += 1 + except Exception as e: + logger.error(f"Failed to send inquiry reminder: {e}") + + if second_reminder_hours > 0 and inquiry.second_reminder_sent_at is None: + hours_since_creation = (now - inquiry.created_at).total_seconds() / 3600 + if hours_since_creation >= second_reminder_hours: + if inquiry.assigned_to and inquiry.assigned_to.email: + try: + NotificationService.send_email( + email=inquiry.assigned_to.email, + subject=f"URGENT: SLA Reminder - Inquiry #{inquiry.id}", + message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. URGENT action required.", + related_object=inquiry, + ) + inquiry.second_reminder_sent_at = now + inquiry.save(update_fields=["second_reminder_sent_at"]) + second_reminder_count += 1 + except Exception as e: + logger.error(f"Failed to send second inquiry reminder: {e}") + + logger.info( + f"Sent {first_reminder_count} first reminders and {second_reminder_count} second reminders for inquiries" + ) + + return { + "first_reminder_count": first_reminder_count, + "second_reminder_count": second_reminder_count, + } diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index b926bf2..5339fc4 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -16,7 +16,7 @@ from django.views.decorators.http import require_http_methods from apps.accounts.models import User from apps.core.services import AuditService -from apps.core.decorators import hospital_admin_required, block_source_user +from apps.core.decorators import hospital_admin_required, block_source_user, px_admin_required from apps.organizations.models import Department, Hospital, Staff from apps.px_sources.models import SourceUser, PXSource @@ -35,6 +35,7 @@ from .models import ( ComplaintAdverseAction, ComplaintAdverseActionAttachment, ) +from .services.complaint_service import ComplaintService, ComplaintServiceError from .forms import ( ComplaintInvolvedDepartmentForm, ComplaintInvolvedStaffForm, @@ -47,35 +48,7 @@ logger = logging.getLogger(__name__) def can_manage_complaint(user, complaint): - """ - Check if user can manage a complaint. - - Returns True if: - - User is PX Admin - - User is Hospital Admin for complaint's hospital - - User is Department Manager for complaint's department - - User is assigned to the complaint - - User is assigned to one of the involved departments - """ - if user.is_px_admin(): - return True - - if user.is_hospital_admin() and user.hospital == complaint.hospital: - return True - - if user.is_department_manager() and user.department == complaint.department: - return True - - if complaint.assigned_to == user: - return True - - # Check if user is assigned to any involved department - if hasattr(complaint, "involved_departments"): - for involved in complaint.involved_departments.all(): - if involved.assigned_to == user: - return True - - return False + return ComplaintService.can_manage(user, complaint) @login_required @@ -496,6 +469,12 @@ def complaint_detail(request, pk): "default_escalation_target": default_escalation_target, "current_user": user, "adverse_actions": adverse_actions, + "show_delay_reason_closure": ( + complaint.delay_reason_closure + or complaint.is_overdue + or (timezone.now() - complaint.created_at).total_seconds() / 3600 > 72 + or complaint.status in ("closed", "resolved") + ), } return render(request, "complaints/complaint_detail.html", context) @@ -552,7 +531,7 @@ def complaint_create(request): source_obj = PXSource.objects.get(code="staff") except PXSource.DoesNotExist: source_obj = PXSource.objects.create( - code="staff", name="Staff", description="Complaints submitted by staff members" + code="staff", name_en="Staff", description="Complaints submitted by staff members" ) complaint.source = source_obj @@ -570,40 +549,7 @@ def complaint_create(request): complaint.save() - # Create initial update - ComplaintUpdate.objects.create( - complaint=complaint, - update_type="note", - message="Complaint created. AI analysis running in background.", - created_by=request.user, - ) - - # Trigger AI analysis in background using Celery - from .tasks import analyze_complaint_with_ai - - analyze_complaint_with_ai.delay(str(complaint.id)) - - # Notify PX Admins about new complaint - # During working hours: ALL admins notified - # Outside working hours: Only ON-CALL admins notified - from .tasks import notify_admins_new_complaint - - notify_admins_new_complaint.delay(str(complaint.id)) - - # Log audit - AuditService.log_event( - event_type="complaint_created", - description=f"Complaint created: {complaint.title}", - user=request.user, - content_object=complaint, - metadata={ - "severity": complaint.severity, - "patient_name": complaint.patient_name, - "national_id": complaint.national_id, - "hospital": complaint.hospital.name if complaint.hospital else None, - "ai_analysis_pending": True, - }, - ) + ComplaintService.post_create_hooks(complaint, request.user, request=request) messages.success( request, @@ -640,20 +586,6 @@ def complaint_assign(request, pk): """Assign complaint to user - Admin only""" complaint = get_object_or_404(Complaint, pk=pk) - # Check if complaint is in active status - if not complaint.is_active_status: - messages.error( - request, - f"Cannot assign complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", - ) - return redirect("complaints:complaint_detail", pk=pk) - - # Check permission - user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): - messages.error(request, "You don't have permission to assign complaints.") - return redirect("complaints:complaint_detail", pk=pk) - user_id = request.POST.get("user_id") if not user_id: messages.error(request, "Please select a user to assign.") @@ -661,31 +593,15 @@ def complaint_assign(request, pk): try: assignee = User.objects.get(id=user_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"Assigned to {assignee.get_full_name()}", - created_by=request.user, - ) - - # Log audit - AuditService.log_event( - event_type="assignment", - description=f"Complaint assigned to {assignee.get_full_name()}", - user=request.user, - content_object=complaint, - ) - - messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.") - + ComplaintService.assign(complaint, assignee, request.user, request=request) except User.DoesNotExist: messages.error(request, "User not found.") + return redirect("complaints:complaint_detail", pk=pk) + except ComplaintServiceError as e: + messages.error(request, str(e)) + return redirect("complaints:complaint_detail", pk=pk) + messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.") return redirect("complaints:complaint_detail", pk=pk) @@ -695,69 +611,105 @@ def complaint_change_status(request, pk): """Change complaint status""" complaint = get_object_or_404(Complaint, pk=pk) - # Check permission - user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): - messages.error(request, "You don't have permission to change complaint status.") - return redirect("complaints:complaint_detail", pk=pk) - new_status = request.POST.get("status") note = request.POST.get("note", "") resolution = request.POST.get("resolution", "") resolution_outcome = request.POST.get("resolution_outcome", "") resolution_outcome_other = request.POST.get("resolution_outcome_other", "") - if not new_status: - messages.error(request, "Please select a status.") + try: + ComplaintService.change_status( + complaint, + new_status, + request.user, + request=request, + note=note, + resolution=resolution, + resolution_outcome=resolution_outcome, + resolution_outcome_other=resolution_outcome_other, + ) + except ComplaintServiceError as e: + messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) - old_status = complaint.status - complaint.status = new_status + messages.success(request, f"Complaint status changed to {new_status}.") + return redirect("complaints:complaint_detail", pk=pk) - # Handle status-specific logic - if new_status == ComplaintStatus.RESOLVED: - complaint.resolved_at = timezone.now() - complaint.resolved_by = request.user - # Save resolution note if provided - if resolution: - complaint.resolution = resolution - complaint.resolution_sent_at = timezone.now() - # Save resolution outcome - if resolution_outcome: - complaint.resolution_outcome = resolution_outcome - if resolution_outcome == "other" and resolution_outcome_other: - complaint.resolution_outcome_other = resolution_outcome_other - elif new_status == ComplaintStatus.CLOSED: - complaint.closed_at = timezone.now() - complaint.closed_by = request.user - # Trigger resolution satisfaction survey - from apps.complaints.tasks import send_complaint_resolution_survey +@login_required +@require_http_methods(["POST"]) +def update_explanation_delay_reason(request, pk): + complaint = get_object_or_404(Complaint, pk=pk) - send_complaint_resolution_survey.delay(str(complaint.id)) + if not can_manage_complaint(request.user, complaint): + messages.error(request, "You don't have permission to update this complaint.") + return redirect("complaints:complaint_detail", pk=pk) - complaint.save() + reason = request.POST.get("explanation_delay_reason", "") + old_reason = complaint.explanation_delay_reason + complaint.explanation_delay_reason = reason + complaint.save(update_fields=["explanation_delay_reason", "updated_at"]) - # Create update ComplaintUpdate.objects.create( complaint=complaint, - update_type="status_change", - message=note or f"Status changed from {old_status} to {new_status}", + update_type="note", + message=f"Explanation delay reason updated: {reason}" if reason else "Explanation delay reason cleared", created_by=request.user, - old_status=old_status, - new_status=new_status, ) - # Log audit AuditService.log_event( - event_type="status_change", - description=f"Complaint status changed from {old_status} to {new_status}", + event_type="update", + description="Explanation delay reason updated", user=request.user, content_object=complaint, - metadata={"old_status": old_status, "new_status": new_status}, + metadata={"old_value": old_reason, "new_value": reason}, ) - messages.success(request, f"Complaint status changed to {new_status}.") + messages.success(request, "Explanation delay reason updated.") + return redirect("complaints:complaint_detail", pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def update_delay_reason_closure(request, pk): + complaint = get_object_or_404(Complaint, pk=pk) + + if not can_manage_complaint(request.user, complaint): + messages.error(request, "You don't have permission to update this complaint.") + return redirect("complaints:complaint_detail", pk=pk) + + if complaint.status in ("closed", "resolved"): + messages.error(request, "Cannot update delay reason for closed/resolved complaints.") + return redirect("complaints:complaint_detail", pk=pk) + + from django.utils import timezone + + hours_since_creation = (timezone.now() - complaint.created_at).total_seconds() / 3600 + if not complaint.is_overdue and hours_since_creation < 72: + messages.error(request, "Delay reason can only be set when complaint is overdue or past 72 hours.") + return redirect("complaints:complaint_detail", pk=pk) + + reason = request.POST.get("delay_reason_closure", "") + old_reason = complaint.delay_reason_closure + complaint.delay_reason_closure = reason + complaint.save(update_fields=["delay_reason_closure", "updated_at"]) + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=f"Closure delay reason updated: {reason}" if reason else "Closure delay reason cleared", + created_by=request.user, + ) + + AuditService.log_event( + event_type="update", + description="Closure delay reason updated", + user=request.user, + content_object=complaint, + metadata={"old_value": old_reason, "new_value": reason}, + ) + + messages.success(request, "Closure delay reason updated.") return redirect("complaints:complaint_detail", pk=pk) @@ -766,22 +718,13 @@ def complaint_change_status(request, pk): def complaint_add_note(request, pk): """Add note to complaint""" complaint = get_object_or_404(Complaint, pk=pk) - - # Check if complaint is in active status - if not complaint.is_active_status: - messages.error( - request, - f"Cannot add notes to complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", - ) - return redirect("complaints:complaint_detail", pk=pk) - note = request.POST.get("note") - if not note: - messages.error(request, "Please enter a note.") - return redirect("complaints:complaint_detail", pk=pk) - # Create update - ComplaintUpdate.objects.create(complaint=complaint, update_type="note", message=note, created_by=request.user) + try: + ComplaintService.add_note(complaint, note, request.user, request=request) + except ComplaintServiceError as e: + messages.error(request, str(e)) + return redirect("complaints:complaint_detail", pk=pk) messages.success(request, "Note added successfully.") return redirect("complaints:complaint_detail", pk=pk) @@ -793,20 +736,6 @@ def complaint_change_department(request, pk): """Change complaint department""" complaint = get_object_or_404(Complaint, pk=pk) - # Check if complaint is in active status - if not complaint.is_active_status: - messages.error( - request, - f"Cannot change department for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", - ) - return redirect("complaints:complaint_detail", pk=pk) - - # Check permission - user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): - messages.error(request, "You don't have permission to change complaint department.") - return redirect("complaints:complaint_detail", pk=pk) - department_id = request.POST.get("department_id") if not department_id: messages.error(request, "Please select a department.") @@ -814,45 +743,15 @@ def complaint_change_department(request, pk): try: department = Department.objects.get(id=department_id) - - # Check department belongs to same hospital - if department.hospital != complaint.hospital: - messages.error(request, "Department does not belong to this complaint's hospital.") - return redirect("complaints:complaint_detail", pk=pk) - - old_department = complaint.department - complaint.department = department - complaint.save(update_fields=["department"]) - - # Create update - ComplaintUpdate.objects.create( - complaint=complaint, - update_type="assignment", - message=f"Department changed to {department.name}", - created_by=request.user, - metadata={ - "old_department_id": str(old_department.id) if old_department else None, - "new_department_id": str(department.id), - }, - ) - - # Log audit - AuditService.log_event( - event_type="department_change", - description=f"Complaint department changed to {department.name}", - user=request.user, - content_object=complaint, - metadata={ - "old_department_id": str(old_department.id) if old_department else None, - "new_department_id": str(department.id), - }, - ) - - messages.success(request, f"Department changed to {department.name}.") - + ComplaintService.change_department(complaint, department, request.user, request=request) except Department.DoesNotExist: messages.error(request, "Department not found.") + return redirect("complaints:complaint_detail", pk=pk) + except ComplaintServiceError as e: + messages.error(request, str(e)) + return redirect("complaints:complaint_detail", pk=pk) + messages.success(request, f"Department changed to {department.name}.") return redirect("complaints:complaint_detail", pk=pk) @@ -897,6 +796,15 @@ def complaint_escalate(request, pk): if escalate_to_staff.user and escalate_to_staff.user.is_active: escalate_to_user = escalate_to_staff.user + # Fallback: use escalation target resolver if still no target + if not escalate_to_user: + from apps.complaints.services.complaint_service import ComplaintService + + fallback_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=complaint.staff) + if fallback_user: + escalate_to_user = fallback_user + reason += f" [fallback via {fallback_path}]" + # Mark as escalated and assign to selected user complaint.escalated_at = timezone.now() if escalate_to_user: @@ -984,84 +892,16 @@ This is an automated message from PX360 Complaint Management System. @login_required @require_http_methods(["POST"]) def complaint_activate(request, pk): - """ - Activate complaint and assign to current user. - - This allows a user to take ownership of an unassigned complaint - or reassign it to themselves. - """ + """Activate complaint and assign to current user.""" complaint = get_object_or_404(Complaint, pk=pk) - # Check if complaint is in active status - if not complaint.is_active_status: - messages.error( - request, - f"Cannot activate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", - ) + try: + ComplaintService.activate(complaint, request.user, request=request) + except ComplaintServiceError as e: + messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) - # Check permission - must be able to edit the complaint - user = request.user - can_activate = ( - user.is_px_admin() - or user.is_hospital_admin() - or (user.is_department_manager() and complaint.department == user.department) - or (complaint.hospital == user.hospital) - ) - - if not can_activate: - messages.error(request, "You don't have permission to activate this complaint.") - return redirect("complaints:complaint_detail", pk=pk) - - # Store previous assignee for logging - previous_assignee = complaint.assigned_to - - # Assign to current user - complaint.assigned_to = user - complaint.assigned_at = timezone.now() - - # Set activated_at if this is the first activation (status is OPEN) - if complaint.status == ComplaintStatus.OPEN: - complaint.status = ComplaintStatus.IN_PROGRESS - complaint.activated_at = timezone.now() - complaint.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"]) - else: - complaint.save(update_fields=["assigned_to", "assigned_at"]) - - # Create update - assign_message = f"Complaint activated and assigned to {user.get_full_name()}" - if previous_assignee: - assign_message += f" (reassigned from {previous_assignee.get_full_name()})" - - ComplaintUpdate.objects.create( - complaint=complaint, - update_type="assignment", - message=assign_message, - created_by=user, - metadata={ - "assigned_to_user_id": str(user.id), - "assigned_to_user_name": user.get_full_name(), - "previous_assignee_id": str(previous_assignee.id) if previous_assignee else None, - "previous_assignee_name": previous_assignee.get_full_name() if previous_assignee else None, - "activation": True, - }, - ) - - # Log audit - AuditService.log_event( - event_type="activation", - description=f"Complaint activated and assigned to {user.get_full_name()}", - user=user, - content_object=complaint, - metadata={ - "assigned_to_user_id": str(user.id), - "assigned_to_user_name": user.get_full_name(), - "previous_assignee_id": str(previous_assignee.id) if previous_assignee else None, - "previous_assignee_name": previous_assignee.get_full_name() if previous_assignee else None, - }, - ) - - messages.success(request, f"Complaint activated and assigned to you successfully.") + messages.success(request, "Complaint activated and assigned to you successfully.") return redirect("complaints:complaint_detail", pk=pk) @@ -1183,6 +1023,116 @@ def complaint_export_excel(request): return export_complaints_excel(queryset, request.GET.dict()) +@login_required +def complaint_export_monthly_calculations(request): + """ + Step 1 — Export monthly calculations report to Excel. + """ + from apps.complaints.utils import export_monthly_calculations + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied("Only PX Admins and Hospital Admins can export.") + + year = request.GET.get("year") + month = request.GET.get("month") + if not year or not month: + from django.contrib import messages + + messages.error(request, "Year and month are required for monthly calculations export.") + return redirect("complaints:complaint_list") + + queryset = Complaint.objects.filter( + created_at__year=int(year), + created_at__month=int(month), + ) + + if request.user.is_hospital_admin() and request.user.hospital: + queryset = queryset.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + queryset = queryset.filter(hospital=request.tenant_hospital) + + queryset = queryset.select_related( + "hospital", + "department", + "main_section", + "subsection", + "assigned_to", + "resolved_by", + "closed_by", + "created_by", + "source", + ).prefetch_related("involved_departments__department") + + return export_monthly_calculations(queryset, int(year), int(month)) + + +@login_required +def complaint_export_quarterly_calculations(request): + """ + Step 2 — Export quarterly calculations report to Excel. + """ + from apps.complaints.utils import export_quarterly_calculations + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied("Only PX Admins and Hospital Admins can export.") + + year = request.GET.get("year") + quarter = request.GET.get("quarter") + if not year or not quarter: + from django.contrib import messages + + messages.error(request, "Year and quarter are required.") + return redirect("complaints:complaint_list") + + year, quarter = int(year), int(quarter) + quarter_months = {1: (1, 3), 2: (4, 6), 3: (7, 9), 4: (10, 12)} + start_month, end_month = quarter_months[quarter] + + queryset = Complaint.objects.filter( + created_at__year=year, + created_at__month__gte=start_month, + created_at__month__lte=end_month, + ) + + if request.user.is_hospital_admin() and request.user.hospital: + queryset = queryset.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + queryset = queryset.filter(hospital=request.tenant_hospital) + + return export_quarterly_calculations(queryset, year, quarter) + + +@login_required +def complaint_export_yearly_calculations(request): + """ + Export yearly calculations report to Excel. + """ + from apps.complaints.utils import export_yearly_calculations + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied("Only PX Admins and Hospital Admins can export.") + + year = request.GET.get("year") + if not year: + from django.contrib import messages + + messages.error(request, "Year is required.") + return redirect("complaints:complaint_list") + + year = int(year) + queryset = Complaint.objects.filter(created_at__year=year) + + if request.user.is_hospital_admin() and request.user.hospital: + queryset = queryset.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + queryset = queryset.filter(hospital=request.tenant_hospital) + + return export_yearly_calculations(queryset, year) + + @hospital_admin_required @require_http_methods(["POST"]) def complaint_bulk_assign(request): @@ -1727,9 +1677,44 @@ def inquiry_respond(request, pk): return redirect("complaints:inquiry_detail", pk=pk) -# ============================================================================ -# ANALYTICS VIEWS -# ============================================================================ +@login_required +def inquiry_export_incoming(request): + """Export incoming inquiries report.""" + from .utils import export_inquiries_report + + year = request.GET.get("year") + month = request.GET.get("month") + if not year or not month: + messages.error(request, "Year and month are required.") + return redirect("complaints:inquiry_list") + + queryset = Inquiry.objects.filter(created_at__year=int(year), created_at__month=int(month), is_outgoing=False) + if request.user.is_hospital_admin() and request.user.hospital: + queryset = queryset.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + queryset = queryset.filter(hospital=request.tenant_hospital) + + return export_inquiries_report(queryset, int(year), int(month), is_outgoing=False) + + +@login_required +def inquiry_export_outgoing(request): + """Export outgoing inquiries report.""" + from .utils import export_inquiries_report + + year = request.GET.get("year") + month = request.GET.get("month") + if not year or not month: + messages.error(request, "Year and month are required.") + return redirect("complaints:inquiry_list") + + queryset = Inquiry.objects.filter(created_at__year=int(year), created_at__month=int(month), is_outgoing=True) + if request.user.is_hospital_admin() and request.user.hospital: + queryset = queryset.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + queryset = queryset.filter(hospital=request.tenant_hospital) + + return export_inquiries_report(queryset, int(year), int(month), is_outgoing=True) @login_required @@ -2382,6 +2367,215 @@ def sla_config_delete(request, pk): return redirect("complaints:sla_config_list") +@px_admin_required +@login_required +def sla_management(request): + """ + PX Admin-only SLA management page. + + Automatically uses the hospital from session (selected after login). + Provides a clean interface to manage complaint SLA configurations. + """ + from .models import ComplaintSLAConfig + from apps.organizations.models import Hospital + + # Get hospital from session (set after login) + hospital_id = request.session.get("selected_hospital_id") + + if not hospital_id: + messages.error(request, "Please select a hospital first.") + return redirect("core:select_hospital") + + # Get the hospital + try: + hospital = Hospital.objects.get(id=hospital_id) + except Hospital.DoesNotExist: + messages.error(request, "Selected hospital not found.") + return redirect("core:select_hospital") + + # Get all SLA configs for this hospital + sla_configs = ( + ComplaintSLAConfig.objects.filter(hospital=hospital, is_active=True) + .select_related("source") + .order_by("source", "severity", "priority") + ) + + # Group configs by type + source_based_configs = sla_configs.filter(source__isnull=False) + severity_based_configs = sla_configs.filter(source__isnull=True) + + context = { + "hospital": hospital, + "source_based_configs": source_based_configs, + "severity_based_configs": severity_based_configs, + "total_configs": sla_configs.count(), + } + + return render(request, "complaints/sla_management.html", context) + + +@px_admin_required +@login_required +@require_http_methods(["GET", "POST"]) +def sla_management_create(request): + """ + PX Admin-only: Create new SLA configuration for selected hospital. + """ + from .models import ComplaintSLAConfig + from .forms import SLAConfigForm + from apps.organizations.models import Hospital + + # Get hospital from session + hospital_id = request.session.get("selected_hospital_id") + + if not hospital_id: + messages.error(request, "Please select a hospital first.") + return redirect("core:select_hospital") + + try: + hospital = Hospital.objects.get(id=hospital_id) + except Hospital.DoesNotExist: + messages.error(request, "Selected hospital not found.") + return redirect("core:select_hospital") + + if request.method == "POST": + form = SLAConfigForm(request.POST, request=request) + + if form.is_valid(): + # Force hospital to session hospital + sla_config = form.save(commit=False) + sla_config.hospital = hospital + sla_config.save() + + # Log audit + AuditService.log_event( + event_type="sla_config_created", + description=f"SLA configuration created: {sla_config}", + user=request.user, + content_object=sla_config, + metadata={ + "hospital": str(sla_config.hospital), + "severity": sla_config.severity, + "priority": sla_config.priority, + "sla_hours": sla_config.sla_hours, + }, + ) + + messages.success(request, "SLA configuration created successfully.") + return redirect("complaints:sla_management") + else: + messages.error(request, "Please correct the errors below.") + else: + form = SLAConfigForm(request=request) + # Pre-select hospital (hidden field) + form.fields["hospital"].initial = hospital.id + + context = { + "form": form, + "hospital": hospital, + "title": "Create SLA Configuration", + "action": "Create", + } + + return render(request, "complaints/sla_management_form.html", context) + + +@px_admin_required +@login_required +@require_http_methods(["GET", "POST"]) +def sla_management_edit(request, pk): + """ + PX Admin-only: Edit SLA configuration for selected hospital. + """ + from .models import ComplaintSLAConfig + from .forms import SLAConfigForm + from apps.organizations.models import Hospital + + # Get hospital from session + hospital_id = request.session.get("selected_hospital_id") + + if not hospital_id: + messages.error(request, "Please select a hospital first.") + return redirect("core:select_hospital") + + try: + hospital = Hospital.objects.get(id=hospital_id) + except Hospital.DoesNotExist: + messages.error(request, "Selected hospital not found.") + return redirect("core:select_hospital") + + sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk) + + if request.method == "POST": + form = SLAConfigForm(request.POST, request=request, instance=sla_config) + + if form.is_valid(): + sla_config = form.save() + + # Log audit + AuditService.log_event( + event_type="sla_config_updated", + description=f"SLA configuration updated: {sla_config}", + user=request.user, + content_object=sla_config, + metadata={ + "hospital": str(sla_config.hospital), + "severity": sla_config.severity, + "priority": sla_config.priority, + "sla_hours": sla_config.sla_hours, + }, + ) + + messages.success(request, "SLA configuration updated successfully.") + return redirect("complaints:sla_management") + else: + messages.error(request, "Please correct the errors below.") + else: + form = SLAConfigForm(request=request, instance=sla_config) + + context = { + "form": form, + "sla_config": sla_config, + "hospital": hospital, + "title": "Edit SLA Configuration", + "action": "Update", + } + + return render(request, "complaints/sla_management_form.html", context) + + +@px_admin_required +@login_required +@require_http_methods(["POST"]) +def sla_management_toggle(request, pk): + """ + PX Admin-only: Toggle SLA configuration active/inactive status. + """ + from .models import ComplaintSLAConfig + + sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk) + + # Toggle status + sla_config.is_active = not sla_config.is_active + sla_config.save() + + # Log audit + AuditService.log_event( + event_type="sla_config_toggled", + description=f"SLA configuration {'activated' if sla_config.is_active else 'deactivated'}: {sla_config}", + user=request.user, + content_object=sla_config, + metadata={ + "hospital": str(sla_config.hospital), + "is_active": sla_config.is_active, + }, + ) + + status_text = "activated" if sla_config.is_active else "deactivated" + messages.success(request, f"SLA configuration {status_text} successfully.") + return redirect("complaints:sla_management") + + @login_required def escalation_rule_list(request): """ @@ -3587,3 +3781,136 @@ def adverse_action_delete(request, pk): messages.error(request, f"Error deleting: {str(e)}") return redirect("complaints:complaint_detail", pk=complaint.id) + + +def patient_complaint_portal(request, token): + """Hospital selection page - first step of patient complaint flow.""" + from apps.complaints.models import PatientComplaintSession + from apps.integrations.models import HISPatientVisit + from django.http import Http404 + from django.db.models import Count + + try: + session = PatientComplaintSession.objects.get(token=token, is_active=True) + except PatientComplaintSession.DoesNotExist: + raise Http404("Invalid or expired link") + + if session.is_expired(): + return render(request, "complaints/patient_complaint_expired.html") + + patient = session.patient + + hospitals = ( + HISPatientVisit.objects.filter(patient=patient) + .values("hospital__id", "hospital__name", "hospital__name_ar") + .annotate(visit_count=Count("id")) + .order_by("-visit_count") + ) + + context = { + "patient": patient, + "session": session, + "hospitals": hospitals, + } + return render(request, "complaints/patient_complaint_portal.html", context) + + +def patient_complaint_hospital_visits(request, token, hospital_id): + """Visit list for a selected hospital - second step.""" + from apps.complaints.models import PatientComplaintSession + from apps.integrations.models import HISPatientVisit + from django.http import Http404 + + try: + session = PatientComplaintSession.objects.get(token=token, is_active=True) + except PatientComplaintSession.DoesNotExist: + raise Http404("Invalid or expired link") + + if session.is_expired(): + return render(request, "complaints/patient_complaint_expired.html") + + patient = session.patient + + visits = HISPatientVisit.objects.filter(patient=patient, hospital_id=hospital_id).order_by("-admit_date")[:50] + + hospital = None + if visits: + hospital = visits[0].hospital + + context = { + "patient": patient, + "session": session, + "hospital": hospital, + "visits": visits, + } + return render(request, "complaints/patient_complaint_visits.html", context) + + +def patient_complaint_visit_form(request, token, visit_id): + """Complaint form for a selected visit - third step.""" + from apps.complaints.models import PatientComplaintSession, Complaint + from apps.integrations.models import HISPatientVisit + from django.http import Http404 + + try: + session = PatientComplaintSession.objects.get(token=token, is_active=True) + except PatientComplaintSession.DoesNotExist: + raise Http404("Invalid or expired link") + + if session.is_expired(): + return render(request, "complaints/patient_complaint_expired.html") + + patient = session.patient + visit = get_object_or_404(HISPatientVisit, id=visit_id, patient=patient) + + if request.method == "POST": + description = request.POST.get("description", "").strip() + if not description: + return render( + request, + "complaints/patient_complaint_visit_form.html", + { + "patient": patient, + "session": session, + "visit": visit, + "error": "Please enter a description of your complaint.", + }, + ) + + title = f"Complaint - {visit.patient_type} Visit ({visit.admission_id})" + complaint = Complaint.objects.create( + patient=patient, + hospital=visit.hospital, + title=title, + description=description, + encounter_id=visit.admission_id, + complaint_source_type="external", + priority="medium", + severity="medium", + status="open", + metadata={ + "session_id": str(session.id), + "visit_id": str(visit.id), + "visit_type": visit.patient_type, + "submitted_via": "patient_link", + }, + ) + + session.is_active = False + session.save() + + return render( + request, + "complaints/patient_complaint_success.html", + { + "complaint": complaint, + "patient": patient, + }, + ) + + context = { + "patient": patient, + "session": session, + "visit": visit, + } + return render(request, "complaints/patient_complaint_visit_form.html", context) diff --git a/apps/complaints/ui_views_explanation.py b/apps/complaints/ui_views_explanation.py index 389dbcc..a37b0a1 100644 --- a/apps/complaints/ui_views_explanation.py +++ b/apps/complaints/ui_views_explanation.py @@ -1,6 +1,7 @@ """ Explanation request UI views for complaints. """ + from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import HttpResponseForbidden @@ -13,7 +14,8 @@ from apps.core.services import AuditService from apps.notifications.services import NotificationService from apps.organizations.models import Staff -from .models import Complaint, ComplaintExplanation +from .models import Complaint, ComplaintExplanation, ComplaintStatus +from .services.complaint_service import ComplaintService, ComplaintServiceError @login_required @@ -21,333 +23,142 @@ from .models import Complaint, ComplaintExplanation def request_explanation_form(request, pk): """ Form to request explanations from involved staff members. - + Shows all involved staff with their managers and departments. All staff and managers are selected by default. """ complaint = get_object_or_404( Complaint.objects.prefetch_related( - 'involved_staff__staff__department', - 'involved_staff__staff__report_to', + "involved_staff__staff__department", + "involved_staff__staff__report_to", ), - pk=pk + pk=pk, ) - + # Check permissions user = request.user can_request = ( - user.is_px_admin() or - user.is_hospital_admin() or - (user.is_department_manager() and complaint.department == user.department) or - (complaint.hospital == user.hospital) + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and complaint.department == user.department) + or (complaint.hospital == user.hospital) ) - + if not can_request: return HttpResponseForbidden(_("You don't have permission to request explanations.")) - + # Check complaint is in active status if not complaint.is_active_status: messages.error( - request, - _("Cannot request explanation for complaint with status '{}'. Complaint must be Open, In Progress, or Partially Resolved.").format( - complaint.get_status_display() - ) + request, + _( + "Cannot request explanation for complaint with status '{}'. Complaint must be Open, In Progress, or Partially Resolved." + ).format(complaint.get_status_display()), ) - return redirect('complaints:complaint_detail', pk=complaint.pk) - + return redirect("complaints:complaint_detail", pk=complaint.pk) + # Get all involved staff with their managers - involved_staff = complaint.involved_staff.select_related( - 'staff', 'staff__department', 'staff__report_to' - ).all() - + involved_staff = complaint.involved_staff.select_related("staff", "staff__department", "staff__report_to").all() + if not involved_staff.exists(): messages.error(request, _("No staff members are involved in this complaint.")) - return redirect('complaints:complaint_detail', pk=complaint.pk) - + return redirect("complaints:complaint_detail", pk=complaint.pk) + # Build list of recipients (staff + managers) recipients = [] manager_ids = set() - + for staff_inv in involved_staff: staff = staff_inv.staff manager = staff.report_to - + recipient = { - 'staff_inv': staff_inv, - 'staff': staff, - 'staff_id': str(staff.id), - 'staff_name': staff.get_full_name(), - 'staff_email': staff.email or (staff.user.email if staff.user else None), - 'department': staff.department.name if staff.department else '-', - 'role': staff_inv.get_role_display(), - 'manager': manager, - 'manager_id': str(manager.id) if manager else None, - 'manager_name': manager.get_full_name() if manager else None, - 'manager_email': manager.email if manager else None, + "staff_inv": staff_inv, + "staff": staff, + "staff_id": str(staff.id), + "staff_name": staff.get_full_name(), + "staff_email": staff.email or (staff.user.email if staff.user else None), + "department": staff.department.name if staff.department else "-", + "role": staff_inv.get_role_display(), + "manager": manager, + "manager_id": str(manager.id) if manager else None, + "manager_name": manager.get_full_name() if manager else None, + "manager_email": manager.email if manager else None, } recipients.append(recipient) - + # Track unique managers if manager and manager.id not in manager_ids: manager_ids.add(manager.id) - - if request.method == 'POST': + + if request.method == "POST": # Get selected staff and managers - selected_staff_ids = request.POST.getlist('selected_staff') - selected_manager_ids = request.POST.getlist('selected_managers') - request_message = request.POST.get('request_message', '').strip() - + selected_staff_ids = request.POST.getlist("selected_staff") + selected_manager_ids = request.POST.getlist("selected_managers") + request_message = request.POST.get("request_message", "").strip() + if not selected_staff_ids: messages.error(request, _("Please select at least one staff member.")) - return render(request, 'complaints/request_explanation_form.html', { - 'complaint': complaint, - 'recipients': recipients, - 'manager_ids': manager_ids, - }) - + return render( + request, + "complaints/request_explanation_form.html", + { + "complaint": complaint, + "recipients": recipients, + "manager_ids": manager_ids, + }, + ) + # Send explanation requests - results = _send_explanation_requests( - request, complaint, recipients, selected_staff_ids, - selected_manager_ids, request_message + from django.contrib.sites.shortcuts import get_current_site + + site = get_current_site(request) + results = ComplaintService.request_explanation( + complaint, + recipients, + selected_staff_ids, + selected_manager_ids, + request_message, + request.user, + site.domain, + request=request, ) - + # Check results and show appropriate message - if results['staff_count'] == 0 and results['manager_count'] == 0: - if results['skipped_no_email'] > 0: + if results["staff_count"] == 0 and results["manager_count"] == 0: + if results["skipped_no_email"] > 0: messages.warning( request, - _("No explanation requests were sent. {} staff member(s) do not have email addresses. Please update staff records with email addresses before sending explanation requests.").format( - results['skipped_no_email'] - ) + _( + "No explanation requests were sent. {} staff member(s) do not have email addresses. Please update staff records with email addresses before sending explanation requests." + ).format(results["skipped_no_email"]), ) else: messages.warning( - request, - _("No explanation requests were sent. Please check staff email configuration.") + request, _("No explanation requests were sent. Please check staff email configuration.") ) - elif results['staff_count'] == 0 and results['manager_count'] > 0: + elif results["staff_count"] == 0 and results["manager_count"] > 0: messages.warning( request, - _("Only manager notifications were sent ({}). Staff explanation requests could not be sent due to missing email addresses.").format( - results['manager_count'] - ) + _( + "Only manager notifications were sent ({}). Staff explanation requests could not be sent due to missing email addresses." + ).format(results["manager_count"]), ) else: messages.success( - request, + request, _("Explanation requests sent successfully! Staff: {}, Managers notified: {}.").format( - results['staff_count'], results['manager_count'] - ) + results["staff_count"], results["manager_count"] + ), ) - return redirect('complaints:complaint_detail', pk=complaint.pk) - - return render(request, 'complaints/request_explanation_form.html', { - 'complaint': complaint, - 'recipients': recipients, - 'manager_ids': manager_ids, - }) + return redirect("complaints:complaint_detail", pk=complaint.pk) - -def _send_explanation_requests(request, complaint, recipients, selected_staff_ids, - selected_manager_ids, request_message): - """ - Send explanation request emails to selected staff and managers. - - Staff receive a link to submit their explanation. - Managers receive a notification email only. - """ - from django.contrib.sites.shortcuts import get_current_site - import secrets - - site = get_current_site(request) - user = request.user - - staff_count = 0 - manager_count = 0 - skipped_no_email = 0 - - # Debug logging - import logging - logger = logging.getLogger(__name__) - logger.info(f"Sending explanation requests. Selected staff IDs: {selected_staff_ids}") - logger.info(f"Selected manager IDs: {selected_manager_ids}") - logger.info(f"Total recipients: {len(recipients)}") - - # Track which managers we've already notified - notified_managers = set() - - for recipient in recipients: - staff = recipient['staff'] - staff_id = recipient['staff_id'] - - logger.info(f"Processing staff: {staff.get_full_name()} (ID: {staff_id})") - - # Skip if staff not selected - if staff_id not in selected_staff_ids: - logger.info(f" Skipping - staff_id {staff_id} not in selected_staff_ids: {selected_staff_ids}") - continue - - # Check if staff has email - staff_email = recipient['staff_email'] - logger.info(f" Staff email: {staff_email}") - if not staff_email: - logger.warning(f" Skipping - no email for staff {staff.get_full_name()}") - skipped_no_email += 1 - continue - - # Generate unique token - staff_token = secrets.token_urlsafe(32) - - # Create or update explanation record - explanation, created = ComplaintExplanation.objects.update_or_create( - complaint=complaint, - staff=staff, - defaults={ - 'token': staff_token, - 'is_used': False, - 'requested_by': user, - 'request_message': request_message, - 'email_sent_at': timezone.now(), - 'submitted_via': 'email_link', - } - ) - - # Build staff email with link - staff_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{staff_token}/" - staff_subject = f"Explanation Request - Complaint #{complaint.reference_number}" - - staff_email_body = f"""Dear {recipient['staff_name']}, - -We have received a complaint that requires your explanation. - -COMPLAINT DETAILS: ----------------- -Reference: {complaint.reference_number} -Title: {complaint.title} -Severity: {complaint.get_severity_display()} -Priority: {complaint.get_priority_display()} - -{complaint.description or 'No description provided.'} - -""" - - # Add patient info if available - if complaint.patient: - staff_email_body += f""" -PATIENT INFORMATION: ------------------- -Name: {complaint.patient.get_full_name()} -MRN: {complaint.patient.mrn or 'N/A'} -""" - - # Add request message if provided - if request_message: - staff_email_body += f""" - -ADDITIONAL MESSAGE: ------------------- -{request_message} -""" - - staff_email_body += f""" - -SUBMIT YOUR EXPLANATION: ------------------------- -Please submit your explanation about this complaint: -{staff_link} - -Note: This link can only be used once. After submission, it will expire. - -If you have any questions, please contact the PX team. - ---- -This is an automated message from PX360 Complaint Management System. -""" - - # Send email to staff - try: - logger.info(f" Sending email to: {staff_email}") - NotificationService.send_email( - email=staff_email, - subject=staff_subject, - message=staff_email_body, - related_object=complaint, - metadata={ - 'notification_type': 'explanation_request', - 'staff_id': str(staff.id), - 'complaint_id': str(complaint.id), - } - ) - staff_count += 1 - logger.info(f" Email sent successfully to {staff_email}") - except Exception as e: - logger.error(f" Failed to send explanation request to staff {staff.id}: {e}") - - # Send notification to manager if selected and not already notified - manager = recipient['manager'] - if manager and recipient['manager_id'] in selected_manager_ids: - if manager.id not in notified_managers: - manager_email = recipient['manager_email'] - if manager_email: - manager_subject = f"Staff Explanation Requested - Complaint #{complaint.reference_number}" - - manager_email_body = f"""Dear {recipient['manager_name']}, - -This is an informational notification that an explanation has been requested from a staff member who reports to you. - -STAFF MEMBER: ------------- -Name: {recipient['staff_name']} -Department: {recipient['department']} -Role in Complaint: {recipient['role']} - -COMPLAINT DETAILS: ----------------- -Reference: {complaint.reference_number} -Title: {complaint.title} -Severity: {complaint.get_severity_display()} - -The staff member has been sent a link to submit their explanation. You will be notified when they respond. - -If you have any questions, please contact the PX team. - ---- -This is an automated message from PX360 Complaint Management System. -""" - - try: - NotificationService.send_email( - email=manager_email, - subject=manager_subject, - message=manager_email_body, - related_object=complaint, - metadata={ - 'notification_type': 'explanation_request_manager_notification', - 'manager_id': str(manager.id), - 'staff_id': str(staff.id), - 'complaint_id': str(complaint.id), - } - ) - manager_count += 1 - notified_managers.add(manager.id) - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Failed to send manager notification to {manager.id}: {e}") - - # Log audit event - AuditService.log_event( - event_type='explanation_request', - description=f'Explanation requests sent to {staff_count} staff and {manager_count} managers ({skipped_no_email} skipped due to no email)', - user=user, - content_object=complaint, - metadata={ - 'staff_count': staff_count, - 'manager_count': manager_count, - 'skipped_no_email': skipped_no_email, - 'selected_staff_ids': selected_staff_ids, - 'selected_manager_ids': selected_manager_ids, - } + return render( + request, + "complaints/request_explanation_form.html", + { + "complaint": complaint, + "recipients": recipients, + "manager_ids": manager_ids, + }, ) - - return {'staff_count': staff_count, 'manager_count': manager_count, 'skipped_no_email': skipped_no_email} diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py index a92465e..f607449 100644 --- a/apps/complaints/urls.py +++ b/apps/complaints/urls.py @@ -32,6 +32,16 @@ urlpatterns = [ path("/", ui_views.complaint_detail, name="complaint_detail"), path("/assign/", ui_views.complaint_assign, name="complaint_assign"), path("/change-status/", ui_views.complaint_change_status, name="complaint_change_status"), + path( + "/update-delay-reason-closure/", + ui_views.update_delay_reason_closure, + name="update_delay_reason_closure", + ), + path( + "/update-explanation-delay-reason/", + ui_views.update_explanation_delay_reason, + name="update_explanation_delay_reason", + ), path("/change-department/", ui_views.complaint_change_department, name="complaint_change_department"), path("/add-note/", ui_views.complaint_add_note, name="complaint_add_note"), path("/escalate/", ui_views.complaint_escalate, name="complaint_escalate"), @@ -39,6 +49,21 @@ urlpatterns = [ # 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"), + path( + "export/monthly-calculations/", + ui_views.complaint_export_monthly_calculations, + name="complaint_export_monthly_calculations", + ), + path( + "export/quarterly-calculations/", + ui_views.complaint_export_quarterly_calculations, + name="complaint_export_quarterly_calculations", + ), + path( + "export/yearly-calculations/", + ui_views.complaint_export_yearly_calculations, + name="complaint_export_yearly_calculations", + ), # 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"), @@ -52,13 +77,20 @@ urlpatterns = [ path("inquiries//change-status/", ui_views.inquiry_change_status, name="inquiry_change_status"), path("inquiries//add-note/", ui_views.inquiry_add_note, name="inquiry_add_note"), path("inquiries//respond/", ui_views.inquiry_respond, name="inquiry_respond"), + path("inquiries/export/incoming/", ui_views.inquiry_export_incoming, name="inquiry_export_incoming"), + path("inquiries/export/outgoing/", ui_views.inquiry_export_outgoing, name="inquiry_export_outgoing"), # Analytics path("analytics/", ui_views.complaints_analytics, name="complaints_analytics"), - # SLA Configuration Management + # SLA Configuration Management (Hospital Admin + PX Admin) path("settings/sla/", ui_views.sla_config_list, name="sla_config_list"), path("settings/sla/new/", ui_views.sla_config_create, name="sla_config_create"), path("settings/sla//edit/", ui_views.sla_config_edit, name="sla_config_edit"), path("settings/sla//delete/", ui_views.sla_config_delete, name="sla_config_delete"), + # PX Admin-only SLA Management (with session-based hospital) + path("settings/sla-management/", ui_views.sla_management, name="sla_management"), + path("settings/sla-management/new/", ui_views.sla_management_create, name="sla_management_create"), + path("settings/sla-management//edit/", ui_views.sla_management_edit, name="sla_management_edit"), + path("settings/sla-management//toggle/", ui_views.sla_management_toggle, name="sla_management_toggle"), # Escalation Rules Management path("settings/escalation-rules/", ui_views.escalation_rule_list, name="escalation_rule_list"), path("settings/escalation-rules/new/", ui_views.escalation_rule_create, name="escalation_rule_create"), @@ -68,8 +100,9 @@ urlpatterns = [ path("settings/thresholds/", ui_views.complaint_threshold_list, name="complaint_threshold_list"), path("settings/thresholds/new/", ui_views.complaint_threshold_create, name="complaint_threshold_create"), path("settings/thresholds//edit/", ui_views.complaint_threshold_edit, name="complaint_threshold_edit"), - path("settings/thresholds//delete/", ui_views.complaint_threshold_delete, name="complaint_threshold_delete"), - + path( + "settings/thresholds//delete/", ui_views.complaint_threshold_delete, name="complaint_threshold_delete" + ), # Complaint Templates Management path("templates/", ui_views_templates.template_list, name="template_list"), path("templates/new/", ui_views_templates.template_create, name="template_create"), @@ -77,7 +110,6 @@ urlpatterns = [ path("templates//edit/", ui_views_templates.template_edit, name="template_edit"), path("templates//delete/", ui_views_templates.template_delete, name="template_delete"), path("templates//toggle/", ui_views_templates.template_toggle_status, name="template_toggle_status"), - # AJAX Helpers path("ajax/departments/", ui_views.get_departments_by_hospital, name="get_departments_by_hospital"), path("ajax/physicians/", ui_views.get_staff_by_department, name="get_physicians_by_department"), @@ -92,10 +124,26 @@ urlpatterns = [ # Location Hierarchy APIs (No Authentication Required) path("public/api/locations/", api_locations, name="api_locations"), path("public/api/locations//sections/", api_sections, name="api_sections"), - path("public/api/locations//sections//subsections/", api_subsections, name="api_subsections"), + path( + "public/api/locations//sections//subsections/", + api_subsections, + name="api_subsections", + ), path("public/api/hospitals//departments/", api_departments, name="api_departments"), # Public Explanation Form (No Authentication Required) path("/explain//", complaint_explanation_form, name="complaint_explanation_form"), + # Patient Complaint Portal (No Authentication Required) + path("patient//", ui_views.patient_complaint_portal, name="patient_complaint_portal"), + path( + "patient//hospital//", + ui_views.patient_complaint_hospital_visits, + name="patient_complaint_hospital_visits", + ), + path( + "patient//visit//", + ui_views.patient_complaint_visit_form, + name="patient_complaint_visit_form", + ), # Resend Explanation path( "/resend-explanation/", @@ -110,7 +158,9 @@ urlpatterns = [ path("departments//remove/", ui_views.involved_department_remove, name="involved_department_remove"), path("departments//response/", ui_views.involved_department_response, name="involved_department_response"), # Request Explanation Form - path("/request-explanation/", ui_views_explanation.request_explanation_form, name="request_explanation_form"), + path( + "/request-explanation/", ui_views_explanation.request_explanation_form, name="request_explanation_form" + ), # Involved Staff Management path("/staff/add/", ui_views.involved_staff_add, name="involved_staff_add"), path("staff//edit/", ui_views.involved_staff_edit, name="involved_staff_edit"), @@ -130,7 +180,9 @@ urlpatterns = [ path("adverse-actions/", ui_views.adverse_action_list, name="adverse_action_list"), path("/adverse-actions/add/", ui_views.adverse_action_add, name="adverse_action_add"), path("adverse-actions//edit/", ui_views.adverse_action_edit, name="adverse_action_edit"), - path("adverse-actions//status/", ui_views.adverse_action_update_status, name="adverse_action_update_status"), + path( + "adverse-actions//status/", ui_views.adverse_action_update_status, name="adverse_action_update_status" + ), path("adverse-actions//escalate/", ui_views.adverse_action_escalate, name="adverse_action_escalate"), path("adverse-actions//delete/", ui_views.adverse_action_delete, name="adverse_action_delete"), # API Routes diff --git a/apps/complaints/utils.py b/apps/complaints/utils.py index 907c997..b2fac04 100644 --- a/apps/complaints/utils.py +++ b/apps/complaints/utils.py @@ -3,113 +3,138 @@ Complaints utility functions Export and bulk operation utilities. """ + import csv import io -from datetime import datetime +from datetime import datetime, timedelta from typing import List +from django.db.models import Count, Q from django.http import HttpResponse +from django.utils import timezone from openpyxl import Workbook -from openpyxl.styles import Font, PatternFill, Alignment +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter 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"' - + 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', - ]) - + 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], - ]) - + 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' + "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]) @@ -117,20 +142,28 @@ def export_complaints_excel(queryset, filters=None): 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=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=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 @@ -143,170 +176,1664 @@ def export_complaints_excel(queryset, filters=None): 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 = 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"' ) - 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'} - + 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']) - + complaint.save(update_fields=["assigned_to", "assigned_at"]) + # Create update ComplaintUpdate.objects.create( complaint=complaint, - update_type='assignment', + update_type="assignment", message=f"Bulk assigned to {assignee.get_full_name()}", - created_by=current_user + 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 - } + + 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 = ''): +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': + if new_status == "resolved": complaint.resolved_at = timezone.now() complaint.resolved_by = current_user - elif new_status == 'closed': + 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', + 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 + 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 - } + + 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 = ''): +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 apps.complaints.services.complaint_service import ComplaintService from django.utils import timezone - + success_count = 0 errors = [] - + not_assigned = 0 + for complaint_id in complaint_ids: try: - complaint = Complaint.objects.get(id=complaint_id) + complaint = Complaint.objects.select_related("staff", "department", "hospital").get(id=complaint_id) complaint.escalated_at = timezone.now() - complaint.save(update_fields=['escalated_at']) - - # Create update + + target_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=complaint.staff) + + escalation_message = f"Bulk escalation. Reason: {reason or 'No reason provided'}" + if target_user: + complaint.assigned_to = target_user + escalation_message += f" Escalated to: {target_user.get_full_name()} [via {fallback_path}]" + else: + not_assigned += 1 + escalation_message += " WARNING: No escalation target found." + + complaint.save(update_fields=["escalated_at", "assigned_to", "updated_at"]) + ComplaintUpdate.objects.create( complaint=complaint, - update_type='escalation', - message=f"Bulk escalation. Reason: {reason or 'No reason provided'}", - created_by=current_user + update_type="escalation", + message=escalation_message, + created_by=current_user, + metadata={ + "reason": reason, + "escalated_to_user_id": str(target_user.id) if target_user else None, + "escalation_fallback_path": fallback_path, + "bulk": True, + }, ) - + 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 + "success": True, + "success_count": success_count, + "total": len(complaint_ids), + "errors": errors, + "not_assigned": not_assigned, } + + +HEADER_FONT = Font(bold=True, color="FFFFFF", size=11) +HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") +HEADER_ALIGNMENT = Alignment(horizontal="center", vertical="center", wrap_text=True) +SECTION_FONT = Font(bold=True, size=11) +SECTION_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid") +THIN_BORDER = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), +) + + +def _write_header_row(ws, row, headers, font=HEADER_FONT, fill=HEADER_FILL, alignment=HEADER_ALIGNMENT): + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=row, column=col_num, value=header) + cell.font = font + cell.fill = fill + cell.alignment = alignment + cell.border = THIN_BORDER + + +def _auto_width(ws, max_width=40): + for column in ws.columns: + max_length = 0 + col_letter = get_column_letter(column[0].column) + for cell in column: + try: + if cell.value: + max_length = max(max_length, len(str(cell.value))) + except Exception: + pass + ws.column_dimensions[col_letter].width = min(max_length + 3, max_width) + + +def export_requests_report(queryset, year=None, month=None): + """ + Step 0 — Requests Report Excel export. + + Generates a monthly Excel report matching the Step 0 template: + - Timeline template sheet with 15 columns + - Monthly data sheet with all request rows + - Summary section with stats and staff breakdown + """ + from apps.dashboard.models import ComplaintRequest + + wb = Workbook() + + ws_template = wb.active + ws_template.title = "Time-Line Template" + + template_headers = [ + "#", + "Request Date", + "Patient Name", + "File Number", + "Complained Department", + "Incident Date", + "Entry (Staff)", + "Time", + "Form Sent Date", + "Time", + "Complaint Filed Date", + "Time", + "Time Between Send & File", + "Non-Activation Reason", + "PR Observations", + ] + _write_header_row(ws_template, 1, template_headers) + for col in range(1, 16): + ws_template.column_dimensions[get_column_letter(col)].width = 18 + + qs = queryset.select_related("staff", "hospital", "complained_department", "complaint") + + month_label = f"{year}-{month:02d}" if year and month else datetime.now().strftime("%Y-%m") + ws_data = wb.create_sheet(title=month_label) + + _write_header_row(ws_data, 1, template_headers) + + row_num = 2 + for idx, req in enumerate(qs, 1): + filled_date = req.filled_at.date() if req.filled_at else "" + filled_time = req.filled_at.strftime("%H:%M") if req.filled_at else "" + form_sent_date = req.form_sent_at.date() if req.form_sent_at else "" + form_sent_time = req.form_sent_time.strftime("%H:%M") if req.form_sent_time else "" + + ws_data.cell(row=row_num, column=1, value=idx) + ws_data.cell(row=row_num, column=2, value=req.request_date) + ws_data.cell(row=row_num, column=3, value=req.patient_name) + ws_data.cell(row=row_num, column=4, value=req.file_number) + ws_data.cell(row=row_num, column=5, value=req.complained_department.name if req.complained_department else "") + ws_data.cell(row=row_num, column=6, value=req.incident_date or "") + ws_data.cell(row=row_num, column=7, value=req.staff.get_full_name() if req.staff else "") + ws_data.cell(row=row_num, column=8, value=req.request_time.strftime("%H:%M") if req.request_time else "") + ws_data.cell(row=row_num, column=9, value=form_sent_date) + ws_data.cell(row=row_num, column=10, value=form_sent_time) + ws_data.cell(row=row_num, column=11, value=filled_date) + ws_data.cell(row=row_num, column=12, value=filled_time) + if req.form_sent_at and req.filled_at: + delta = req.filled_at - req.form_sent_at + ws_data.cell(row=row_num, column=13, value=str(delta)) + ws_data.cell(row=row_num, column=14, value=req.get_reason_non_activation_display() or "") + ws_data.cell(row=row_num, column=15, value=req.pr_observations) + for col in range(1, 16): + ws_data.cell(row=row_num, column=col).border = THIN_BORDER + row_num += 1 + + summary_row = row_num + 2 + stats = qs.aggregate( + on_hold_count=Count("pk", filter=Q(on_hold=True)), + not_filled_count=Count("pk", filter=Q(not_filled=True)), + filled_count=Count("pk", filter=Q(filled=True)), + barcode_count=Count("pk", filter=Q(from_barcode=True)), + same_time=Count("pk", filter=Q(filling_time_category="same_time")), + within_6h=Count("pk", filter=Q(filling_time_category="within_6h")), + six_to_24h=Count("pk", filter=Q(filling_time_category="6_to_24h")), + after_1_day=Count("pk", filter=Q(filling_time_category="after_1_day")), + not_mentioned=Count("pk", filter=Q(filling_time_category="not_mentioned")), + ) + total = qs.count() + + summary_items = [ + ("Total Complaints on Hold", stats["on_hold_count"]), + ("Total Not Filled", stats["not_filled_count"]), + ("Total Filled", stats["filled_count"]), + ("Total from Barcode (SELF)", stats["barcode_count"]), + ("Filled at the same time", stats["same_time"]), + ("Filled within 6 hours", stats["within_6h"]), + ("Filled from 6 to 24 hours", stats["six_to_24h"]), + ("Filled after 1 day", stats["after_1_day"]), + ("Time not mentioned", stats["not_mentioned"]), + ("TOTAL", total), + ] + ws_data.cell(row=summary_row, column=1, value="Summary").font = SECTION_FONT + summary_row += 1 + for label, val in summary_items: + ws_data.cell(row=summary_row, column=1, value=label).font = Font(bold=(label == "TOTAL")) + ws_data.cell(row=summary_row, column=2, value=val) + summary_row += 1 + + summary_row += 1 + ws_data.cell(row=summary_row, column=1, value="Staff Breakdown").font = SECTION_FONT + summary_row += 1 + _write_header_row(ws_data, summary_row, ["Staff", "Total", "Filled", "Not Filled"]) + summary_row += 1 + + staff_stats = ( + qs.values("staff__first_name", "staff__last_name", "staff__id") + .annotate( + total=Count("pk"), + filled=Count("pk", filter=Q(filled=True)), + not_filled=Count("pk", filter=Q(not_filled=True)), + ) + .order_by("-total") + ) + + for s in staff_stats: + name = f"{s['staff__first_name'] or ''} {s['staff__last_name'] or ''}".strip() or "Unknown" + ws_data.cell(row=summary_row, column=1, value=name) + ws_data.cell(row=summary_row, column=2, value=s["total"]) + ws_data.cell(row=summary_row, column=3, value=s["filled"]) + ws_data.cell(row=summary_row, column=4, value=s["not_filled"]) + for col in range(1, 5): + ws_data.cell(row=summary_row, column=col).border = THIN_BORDER + summary_row += 1 + + summary_row += 1 + ws_data.cell(row=summary_row, column=1, value="Non-Activation Reasons").font = SECTION_FONT + summary_row += 1 + _write_header_row(ws_data, summary_row, ["Reason", "Count", "Percentage"]) + summary_row += 1 + + reason_stats = ( + qs.exclude(reason_non_activation="") + .values("reason_non_activation") + .annotate(count=Count("pk")) + .order_by("-count") + ) + + for r in reason_stats: + display = dict(ComplaintRequest.NON_ACTIVATION_REASON_CHOICES).get( + r["reason_non_activation"], r["reason_non_activation"] + ) + pct = (r["count"] / total * 100) if total else 0 + ws_data.cell(row=summary_row, column=1, value=display) + ws_data.cell(row=summary_row, column=2, value=r["count"]) + ws_data.cell(row=summary_row, column=3, value=f"{pct:.1f}%") + for col in range(1, 4): + ws_data.cell(row=summary_row, column=col).border = THIN_BORDER + summary_row += 1 + + _auto_width(ws_data, 50) + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = f'attachment; filename="requests_report_{month_label}.xlsx"' + wb.save(response) + return response + + +def export_monthly_calculations(queryset, year, month): + """ + Step 1 — Monthly Calculations Excel export. + + Generates the full 108-column monthly calculations report + matching the Step 1 template structure, including: + - Main data sheet with all columns A-BN + - Summary sections: weekly, source, location, department, + sub-department, employee performance, activation delay + """ + from apps.complaints.models import Complaint, ComplaintInvolvedDepartment + + wb = Workbook() + ws = wb.active + ws.title = month_label = f"{year}-{month:02d}" + + headers = [ + "Week", + "No.", + "Complaint ID", + "File Number", + "Source", + "Location", + "Main Department", + "Sub-Department", + "Date Received", + "Entered By", + "MOH Ref", + "MOH Ref Date", + "MOH Ref Time", + "Phone Number", + "Timeline SLA", + "Form Sent Date", + "Form Sent Time", + "Employee (Form Sent)", + "Complaint Filed Date", + "Complaint Filed Time", + "Activated", + "Activation Date", + "Activation Time", + "Employee (Activation)", + "Sent to Dept Date", + "Sent to Dept Time", + "Employee (Dept Send)", + "Time: Filed to Sent", + "1st Reminder Date", + "1st Reminder Time", + "Employee (1st Rem)", + "Time: 1st Rem to Sent", + "2nd Reminder Date", + "2nd Reminder Time", + "Employee (2nd Rem)", + "Time: 2nd Rem to 1st Rem", + "Escalated", + "Escalation Date", + "Escalation Time", + "Employee (Escalation)", + "Time: Escalation to Sent", + "Closed", + "Close Date", + "Close Time", + "Employee (Close)", + "Time: Close to Sent", + "Resolved", + "Resolve Date", + "Resolve Time", + "Employee (Resolve)", + "Time: Resolve to Sent", + "Response Date", + "Response Time", + "Time: Response to Sent", + "Complained Person Name", + "Main Complaint Subject", + "Summary (Arabic)", + "Summary (English)", + "Reminder Documentation", + "Reminder Date", + "Delay Reason (Dept)", + "Delay Reason (Closure 72h)", + "Person Responsible for Delay", + "Satisfaction", + "Action Taken by Dept", + "Investigation Result", + "Solutions & Suggestions", + "Recommendation/Action Plan", + "Responsible Department", + "Rightful Side", + "PR Observations", + ] + + _write_header_row(ws, 1, headers) + ws.freeze_panes = "D2" + + qs = queryset.select_related( + "hospital", + "department", + "main_section", + "subsection", + "assigned_to", + "resolved_by", + "closed_by", + "created_by", + "source", + ).prefetch_related("involved_departments__department", "updates") + + dept_cat_keywords = { + "medical": [ + "doctor", + "physician", + "surgeon", + "consultant", + "specialist", + "er", + "emergency", + "icu", + "nicu", + "pediatric", + "ob/gyn", + "obstetric", + "gynecolog", + "cardiology", + "orthoped", + "radiology", + "dermatolog", + "patholog", + "lab", + "laboratory", + "pharmacy", + "anesthesi", + "nephrology", + "urology", + "dental", + "dentist", + "ophthalmol", + "ent", + "otorhinolaryng", + "pulmonar", + "respirator", + "oncolog", + "hematolog", + "gastroenter", + "endocrin", + "neurolog", + "psychiatry", + "psychiatric", + "internal medicine", + "general surgery", + "pediatrics", + "neonat", + "nutrition", + "dietitian", + "physiothera", + "physical therapy", + "rehab", + "speech therap", + "occupational", + "medical report", + "blood bank", + "infection control", + ], + "admin": [ + "reception", + "registration", + "appointment", + "approval", + "insurance", + "finance", + "billing", + "account", + "hr", + "human resource", + "it ", + "information technology", + "medical record", + "health information", + "management", + "admin", + "security", + "parking", + "facility", + "maintenance", + "housekeep", + "clean", + "food", + "kitchen", + "cafeteria", + "transport", + "patient relation", + "pr ", + "public relation", + "complaint", + "quality", + "risk", + "credential", + "medical approval", + "pre-approval", + "preapproval", + ], + "nursing": [ + "nurs", + "nurse", + "iv ", + "injection", + "medication admin", + "wound care", + "triage", + ], + "support": [ + "kitchen", + "food service", + "clean", + "housekeep", + "laundry", + "security", + "transport", + "maintenance", + "facility", + "steriliz", + "central supply", + ], + } + + def classify_department(dept_name): + if not dept_name: + return "" + name_lower = dept_name.lower() + for cat, keywords in dept_cat_keywords.items(): + for kw in keywords: + if kw in name_lower: + return cat.title() + return "Other" + + def get_timeline_sla(complaint): + if not complaint.resolved_at or not complaint.created_at: + return "More than 72 hours" + delta = complaint.resolved_at - complaint.created_at + hours = delta.total_seconds() / 3600 + if hours <= 24: + return "24 Hours" + elif hours <= 48: + return "48 Hours" + elif hours <= 72: + return "72 Hours" + return "More than 72 hours" + + def get_dept_primary(complaint): + dept = complaint.involved_departments.filter(is_primary=True).first() + if not dept: + dept = complaint.involved_departments.first() + return dept + + def fmt_date(dt): + if not dt: + return "" + return dt.date() if hasattr(dt, "date") else dt + + def fmt_time(dt): + if not dt: + return "" + return dt.strftime("%H:%M") if hasattr(dt, "strftime") else "" + + def time_diff(dt1, dt2): + if dt1 and dt2: + delta = dt1 - dt2 + return str(delta) + return "" + + row_num = 2 + for idx, c in enumerate(qs, 1): + cal = c.created_at + week_of_month = (cal.day - 1) // 7 + 1 + + dept_name = "" + sub_dept_name = "" + dept_inv = get_dept_primary(c) + if dept_inv: + dept_name = dept_inv.department.name if dept_inv.department else "" + sub_dept_name = dept_name + + main_dept = classify_department(dept_name or (c.department.name if c.department else "")) + + source_display = "" + if c.source: + source_display = c.source.name if hasattr(c.source, "name") else str(c.source) + if c.complaint_source_type == "internal": + if c.source and c.source.code in ("moh", "chi"): + pass + else: + source_display = "Patient" + + location_display = "" + if c.main_section: + location_display = c.main_section.name if hasattr(c.main_section, "name") else str(c.main_section) + if c.location: + loc_name = c.location.name if hasattr(c.location, "name") else str(c.location) + if loc_name: + location_display = f"{loc_name} - {location_display}" if location_display else loc_name + + entry_by = c.created_by.get_full_name() if c.created_by else "" + + activation_date = c.activated_at or c.assigned_at + activation_employee = "" + if c.assigned_to: + activation_employee = c.assigned_to.get_full_name() + + forwarded_date = c.forwarded_to_dept_at + forwarded_employee = "" + if dept_inv and dept_inv.assigned_to: + forwarded_employee = dept_inv.assigned_to.get_full_name() + + reminder1_date = c.reminder_sent_at + reminder2_date = c.second_reminder_sent_at + if dept_inv: + if dept_inv.first_reminder_sent_at: + reminder1_date = dept_inv.first_reminder_sent_at + if dept_inv.second_reminder_sent_at: + reminder2_date = dept_inv.second_reminder_sent_at + + escalated_date = c.escalated_at + + filed_date = cal + time_filed_to_sent = time_diff(forwarded_date, filed_date) + time_rem1_to_sent = time_diff(forwarded_date, reminder1_date) if reminder1_date and forwarded_date else "" + time_rem2_to_rem1 = time_diff(reminder2_date, reminder1_date) if reminder2_date and reminder1_date else "" + time_escal_to_sent = time_diff(forwarded_date, escalated_date) if escalated_date and forwarded_date else "" + + close_date = c.closed_at + resolve_date = c.resolved_at + + summary_ar = c.short_description_ar + summary_en = c.short_description_en + + complained_person = c.staff_name or "" + + delay_reason_dept = "" + delayed_person = "" + if dept_inv: + delay_reason_dept = dept_inv.delay_reason + delayed_person = dept_inv.delayed_person + + rightful_side = c.get_resolution_outcome_display() if c.resolution_outcome else "" + + row_data = [ + week_of_month, + idx, + c.reference_number or "", + c.file_number or (c.patient.mrn if c.patient else ""), + source_display, + location_display, + main_dept, + sub_dept_name, + fmt_date(cal), + entry_by, + c.moh_reference, + fmt_date(c.moh_reference_date), + "", + c.contact_phone, + get_timeline_sla(c), + fmt_date(c.form_sent_at), + fmt_time(c.form_sent_at), + c.created_by.get_full_name() if c.created_by else "", + fmt_date(filed_date), + fmt_time(filed_date), + "Yes" if activation_date else "No", + fmt_date(activation_date), + fmt_time(activation_date), + activation_employee, + fmt_date(forwarded_date), + fmt_time(forwarded_date), + forwarded_employee, + time_filed_to_sent, + fmt_date(reminder1_date), + fmt_time(reminder1_date), + "", + time_rem1_to_sent, + fmt_date(reminder2_date), + fmt_time(reminder2_date), + "", + time_rem2_to_rem1, + "Yes" if escalated_date else "No", + fmt_date(escalated_date), + fmt_time(escalated_date), + "", + time_escal_to_sent, + "Yes" if close_date else "No", + fmt_date(close_date), + fmt_time(close_date), + c.closed_by.get_full_name() if c.closed_by else "", + time_diff(resolve_date, close_date) if resolve_date and close_date else "", + "Yes" if resolve_date else "No", + fmt_date(resolve_date), + fmt_time(resolve_date), + c.resolved_by.get_full_name() if c.resolved_by else "", + time_diff(c.resolution_sent_at, resolve_date) if c.resolution_sent_at and resolve_date else "", + fmt_date(c.response_date), + "", + time_diff(c.response_date, resolve_date) if c.response_date and resolve_date else "", + complained_person, + c.complaint_subject or c.title, + summary_ar, + summary_en, + "", + "", + delay_reason_dept, + c.delay_reason_closure, + delayed_person, + c.get_satisfaction_display() if c.satisfaction else "", + c.action_taken_by_dept, + c.action_result, + c.recommendation_action_plan or "", + c.recommendation_action_plan or "", + dept_name or (c.department.name if c.department else ""), + rightful_side, + "", + ] + + for col_num, val in enumerate(row_data, 1): + cell = ws.cell(row=row_num, column=col_num, value=val) + cell.border = THIN_BORDER + cell.alignment = Alignment(vertical="center", wrap_text=True) + row_num += 1 + + summary_row = row_num + 2 + + ws.cell(row=summary_row, column=1, value="Weekly Breakdown").font = SECTION_FONT + summary_row += 1 + _write_header_row(ws, summary_row, ["Metric", "Week 1", "Week 2", "Week 3", "Week 4", "Week 5", "Total"]) + summary_row += 1 + for week in range(1, 6): + start_day = (week - 1) * 7 + 1 + end_day = week * 7 + week_count = sum( + 1 for c in queryset if start_day <= c.created_at.day <= end_day and c.created_at.month == month + ) + ws.cell(row=summary_row, column=week + 1, value=week_count) + ws.cell(row=summary_row, column=7, value=queryset.count()) + summary_row += 2 + + ws.cell(row=summary_row, column=1, value="Source Distribution").font = SECTION_FONT + summary_row += 1 + _write_header_row(ws, summary_row, ["Source", "Count", "%"]) + summary_row += 1 + total = queryset.count() + source_counts = {"MOH": 0, "CCHI": 0, "Patient": 0, "Patient's Relatives": 0, "Other": 0} + for c in queryset: + if c.source and c.source.code == "moh": + source_counts["MOH"] += 1 + elif c.source and c.source.code == "chi": + source_counts["CCHI"] += 1 + elif c.complaint_source_type == "internal": + if c.source and c.source.code == "chi": + source_counts["CCHI"] += 1 + elif c.source and c.source.code == "moh": + source_counts["MOH"] += 1 + else: + source_counts["Patient"] += 1 + else: + source_counts["Other"] += 1 + for src, cnt in source_counts.items(): + if cnt > 0: + pct = (cnt / total * 100) if total else 0 + ws.cell(row=summary_row, column=1, value=src) + ws.cell(row=summary_row, column=2, value=cnt) + ws.cell(row=summary_row, column=3, value=f"{pct:.1f}%") + for col in range(1, 4): + ws.cell(row=summary_row, column=col).border = THIN_BORDER + summary_row += 1 + ws.cell(row=summary_row, column=1, value="Total").font = Font(bold=True) + ws.cell(row=summary_row, column=2, value=total) + summary_row += 2 + + ws.cell(row=summary_row, column=1, value="Department Category Breakdown").font = SECTION_FONT + summary_row += 1 + _write_header_row(ws, summary_row, ["Category", "Count", "%"]) + summary_row += 1 + dept_categories = {} + for c in queryset: + dept_name_raw = "" + dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first() + if dept_inv and dept_inv.department: + dept_name_raw = dept_inv.department.name + elif c.department: + dept_name_raw = c.department.name + cat = classify_department(dept_name_raw) or "Other" + dept_categories[cat] = dept_categories.get(cat, 0) + 1 + for cat, cnt in sorted(dept_categories.items(), key=lambda x: -x[1]): + pct = (cnt / total * 100) if total else 0 + ws.cell(row=summary_row, column=1, value=cat) + ws.cell(row=summary_row, column=2, value=cnt) + ws.cell(row=summary_row, column=3, value=f"{pct:.1f}%") + for col in range(1, 4): + ws.cell(row=summary_row, column=col).border = THIN_BORDER + summary_row += 1 + summary_row += 2 + + ws.cell(row=summary_row, column=1, value="Employee Performance").font = SECTION_FONT + summary_row += 1 + _write_header_row(ws, summary_row, ["Employee", "Count", "%", "24h", "48h", "72h", ">72h", "Total", "SELF"]) + summary_row += 1 + + emp_stats = ( + queryset.values("created_by__first_name", "created_by__last_name") + .annotate( + total=Count("pk"), + ) + .order_by("-total") + ) + + for emp in emp_stats: + name = f"{emp['created_by__first_name'] or ''} {emp['created_by__last_name'] or ''}".strip() or "Unknown" + emp_qs = queryset.filter( + created_by__first_name=emp["created_by__first_name"], created_by__last_name=emp["created_by__last_name"] + ) + h24 = sum(1 for c in emp_qs if c.resolved_at and (c.resolved_at - c.created_at).total_seconds() <= 86400) + h48 = sum( + 1 for c in emp_qs if c.resolved_at and 86400 < (c.resolved_at - c.created_at).total_seconds() <= 172800 + ) + h72 = sum( + 1 for c in emp_qs if c.resolved_at and 172800 < (c.resolved_at - c.created_at).total_seconds() <= 259200 + ) + over72 = emp["total"] - h24 - h48 - h72 + self_count = sum(1 for c in emp_qs if c.created_by == c.assigned_to) + pct = (emp["total"] / total * 100) if total else 0 + row_vals = [name, emp["total"], f"{pct:.1f}%", h24, h48, h72, max(0, over72), emp["total"], self_count] + for col_num, val in enumerate(row_vals, 1): + ws.cell(row=summary_row, column=col_num, value=val) + ws.cell(row=summary_row, column=col_num).border = THIN_BORDER + summary_row += 1 + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = f'attachment; filename="monthly_calculations_{month_label}.xlsx"' + wb.save(response) + return response + + +def _build_quarterly_yearly_report(queryset, title, months_list, year=None): + """ + Shared logic for quarterly and yearly calculations reports. + + Args: + queryset: Complaint queryset (already filtered by date range) + title: Sheet/report title (e.g., "Q1 2025" or "Yearly 2025") + months_list: list of (month_num,) tuples for each month in the period + year: year number (optional, for sheet naming) + """ + from apps.complaints.models import Complaint, ComplaintInvolvedDepartment + + wb = Workbook() + + ws_kpi = wb.active + ws_kpi.title = "1. KPI" + + dept_cat_keywords = { + "medical": [ + "doctor", + "physician", + "surgeon", + "consultant", + "specialist", + "er", + "emergency", + "icu", + "nicu", + "pediatric", + "ob/gyn", + "cardiology", + "orthoped", + "radiology", + "dermatolog", + "lab", + "pharmacy", + "anesthesi", + "nephrology", + "urology", + "dental", + "oncolog", + "hematolog", + "gastroenter", + "endocrin", + "neurolog", + "psychiatry", + "internal medicine", + "general surgery", + "pediatrics", + "nutrition", + "physiothera", + "physical therapy", + "rehab", + "medical report", + "blood bank", + "infection control", + ], + "admin": [ + "reception", + "appointment", + "approval", + "insurance", + "finance", + "billing", + "hr", + "medical record", + "management", + "admin", + "security", + "facility", + "quality", + "risk", + "credential", + "medical approval", + "pre-approval", + "preapproval", + "it ", + ], + "nursing": ["nurs", "nurse", "iv ", "injection", "medication admin", "wound care", "triage"], + "support": ["kitchen", "food service", "clean", "housekeep", "laundry", "steriliz", "central supply"], + } + + def classify_department(dept_name): + if not dept_name: + return "Other" + name_lower = dept_name.lower() + for cat, keywords in dept_cat_keywords.items(): + for kw in keywords: + if kw in name_lower: + return cat.title() + return "Other" + + month_labels = [datetime.strptime(f"{year}-{m:02d}", "%Y-%m").strftime("%b") for _, m in months_list] + + complaints_list = list( + queryset.select_related( + "hospital", + "department", + "source", + "created_by", + "assigned_to", + "resolved_by", + "closed_by", + ).prefetch_related("involved_departments__department") + ) + + month_complaints = {} + for c in complaints_list: + m_key = c.created_at.month + if m_key not in month_complaints: + month_complaints[m_key] = [] + month_complaints[m_key].append(c) + + def _month_qs(m): + return month_complaints.get(m, []) + + row = 1 + ws_kpi.cell(row=row, column=1, value=title).font = Font(bold=True, size=14) + row += 2 + + def write_kpi_block(name, monthly_values, target=0.95, threshold=0.90): + nonlocal row + ws_kpi.cell(row=row, column=1, value=name).font = Font(bold=True, size=12) + row += 1 + + numerator_label_row = row + ws_kpi.cell(row=row, column=1, value="Numerator") + for i, (m_num, _) in enumerate(months_list): + ws_kpi.cell(row=row, column=2 + i, value=monthly_values[i][0]) + total_num = sum(v[0] for v in monthly_values) + ws_kpi.cell(row=row, column=2 + len(months_list), value=total_num) + row += 1 + + ws_kpi.cell(row=row, column=1, value="Denominator") + for i, (m_num, _) in enumerate(months_list): + ws_kpi.cell(row=row, column=2 + i, value=monthly_values[i][1]) + total_den = sum(v[1] for v in monthly_values) + ws_kpi.cell(row=row, column=2 + len(months_list), value=total_den) + row += 1 + + ws_kpi.cell(row=row, column=1, value="Result (%)") + for i, (m_num, _) in enumerate(months_list): + pct = (monthly_values[i][0] / monthly_values[i][1] * 100) if monthly_values[i][1] else 0 + ws_kpi.cell(row=row, column=2 + i, value=f"{pct:.1f}%") + total_pct = (total_num / total_den * 100) if total_den else 0 + ws_kpi.cell(row=row, column=2 + len(months_list), value=f"{total_pct:.1f}%") + row += 2 + + kpi1_data = [] + kpi2_data = [] + kpi4_data = [] + for m_num, _ in months_list: + mc = _month_qs(m_num) + total_m = len(mc) + closed_m = sum(1 for c in mc if c.status in ("resolved", "closed")) + kpi1_data.append((closed_m, total_m)) + + resolved_72h = 0 + for c in mc: + if c.resolved_at and c.created_at: + if (c.resolved_at - c.created_at).total_seconds() <= 259200: + resolved_72h += 1 + kpi2_data.append((resolved_72h, total_m)) + + resolved_48h = 0 + for c in mc: + if c.resolved_at and c.created_at: + if (c.resolved_at - c.created_at).total_seconds() <= 172800: + resolved_48h += 1 + kpi4_data.append((resolved_48h, total_m)) + + write_kpi_block("KPI #1 — Closure Rate", kpi1_data) + write_kpi_block("KPI #2 — Resolved Within 72 Hours", kpi2_data) + write_kpi_block("KPI #4 — Responses Within 48 Hours", kpi4_data, target=0.50, threshold=0.40) + + kpi3_data = [] + for m_num, _ in months_list: + mc = _month_qs(m_num) + satisfied_m = sum(1 for c in mc if c.satisfaction == "satisfied") + total_surveyed_m = sum(1 for c in mc if c.satisfaction in ("satisfied", "neutral", "dissatisfied")) + kpi3_data.append((satisfied_m, total_surveyed_m)) + + write_kpi_block("KPI #3 — Satisfaction Rate", kpi3_data, target=0.80, threshold=0.70) + + response_rate_call_data = [] + response_rate_survey_data = [] + for m_num, _ in months_list: + mc = _month_qs(m_num) + total_m = len(mc) + responded_call = sum(1 for c in mc if c.satisfaction in ("satisfied", "neutral", "dissatisfied")) + responded_survey = sum(1 for c in mc if c.resolution_survey_id is not None) + response_rate_call_data.append((responded_call, total_m)) + response_rate_survey_data.append((responded_survey, total_m)) + + write_kpi_block("Response Rate — Calls", response_rate_call_data, target=0.80, threshold=0.70) + write_kpi_block("Response Rate — Survey", response_rate_survey_data, target=0.80, threshold=0.70) + + for col in range(1, 3 + len(months_list)): + ws_kpi.column_dimensions[get_column_letter(col)].width = 16 + + ws_table = wb.create_sheet("2. First Table") + row = 1 + ws_table.cell(row=row, column=1, value=f"{title} — Distribution Analysis").font = Font(bold=True, size=14) + row += 2 + + external = 0 + internal = 0 + source_breakdown = {"MOH": 0, "CCHI": 0, "Insurance": 0, "Internal": 0} + location_breakdown = {"In-Patient": 0, "Out-Patient": 0, "ER": 0} + category_breakdown = {} + + for c in complaints_list: + is_external = False + if c.source and c.source.code == "moh": + source_breakdown["MOH"] += 1 + external += 1 + is_external = True + elif c.source and c.source.code == "chi": + source_breakdown["CCHI"] += 1 + external += 1 + is_external = True + else: + source_breakdown["Internal"] += 1 + internal += 1 + + loc = "Other" + if c.main_section: + loc_name = c.main_section.name.lower() if hasattr(c.main_section, "name") else str(c.main_section).lower() + if "inpatient" in loc_name or "ip" in loc_name: + loc = "In-Patient" + elif "outpatient" in loc_name or "op" in loc_name or "clinic" in loc_name: + loc = "Out-Patient" + elif "er" in loc_name or "emergency" in loc_name: + loc = "ER" + location_breakdown[loc] = location_breakdown.get(loc, 0) + 1 + + dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first() + dept_name = "" + if dept_inv and dept_inv.department: + dept_name = dept_inv.department.name + elif c.department: + dept_name = c.department.name + cat = classify_department(dept_name) + category_breakdown[cat] = category_breakdown.get(cat, 0) + 1 + + total = len(complaints_list) + + ws_table.cell(row=row, column=1, value="External vs Internal").font = SECTION_FONT + row += 1 + _write_header_row(ws_table, row, ["Category", "Count", "%"]) + row += 1 + for label, count in [("External (MOH/CCHI/Insurance)", external), ("Internal (Patients/Relatives)", internal)]: + pct = (count / total * 100) if total else 0 + ws_table.cell(row=row, column=1, value=label) + ws_table.cell(row=row, column=2, value=count) + ws_table.cell(row=row, column=3, value=f"{pct:.1f}%") + for col in range(1, 4): + ws_table.cell(row=row, column=col).border = THIN_BORDER + row += 1 + ws_table.cell(row=row, column=1, value="Total").font = Font(bold=True) + ws_table.cell(row=row, column=2, value=total).font = Font(bold=True) + row += 2 + + ws_table.cell(row=row, column=1, value="By Source").font = SECTION_FONT + row += 1 + _write_header_row(ws_table, row, ["Source", "Count", "%"]) + row += 1 + for src, cnt in sorted(source_breakdown.items(), key=lambda x: -x[1]): + pct = (cnt / total * 100) if total else 0 + ws_table.cell(row=row, column=1, value=src) + ws_table.cell(row=row, column=2, value=cnt) + ws_table.cell(row=row, column=3, value=f"{pct:.1f}%") + for col in range(1, 4): + ws_table.cell(row=row, column=col).border = THIN_BORDER + row += 1 + row += 2 + + ws_table.cell(row=row, column=1, value="By Location").font = SECTION_FONT + row += 1 + _write_header_row(ws_table, row, ["Location", "Count", "%"]) + row += 1 + for loc, cnt in sorted(location_breakdown.items(), key=lambda x: -x[1]): + pct = (cnt / total * 100) if total else 0 + ws_table.cell(row=row, column=1, value=loc) + ws_table.cell(row=row, column=2, value=cnt) + ws_table.cell(row=row, column=3, value=f"{pct:.1f}%") + for col in range(1, 4): + ws_table.cell(row=row, column=col).border = THIN_BORDER + row += 1 + row += 2 + + ws_table.cell(row=row, column=1, value="By Department Category").font = SECTION_FONT + row += 1 + _write_header_row(ws_table, row, ["Category", "Count", "%"]) + row += 1 + for cat, cnt in sorted(category_breakdown.items(), key=lambda x: -x[1]): + pct = (cnt / total * 100) if total else 0 + ws_table.cell(row=row, column=1, value=cat) + ws_table.cell(row=row, column=2, value=cnt) + ws_table.cell(row=row, column=3, value=f"{pct:.1f}%") + for col in range(1, 4): + ws_table.cell(row=row, column=col).border = THIN_BORDER + row += 1 + + for col in range(1, 5): + ws_table.column_dimensions[get_column_letter(col)].width = 35 + + ws_escalated = wb.create_sheet("3. Escalated Complaints") + row = 1 + ws_escalated.cell(row=row, column=1, value=f"{title} — Escalated Complaints by Department").font = Font( + bold=True, size=14 + ) + row += 2 + + escalated_by_dept = {} + escalated_by_cat = {} + for c in complaints_list: + if c.escalated_at: + dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first() + dept_name = "" + if dept_inv and dept_inv.department: + dept_name = dept_inv.department.name + elif c.department: + dept_name = c.department.name + if dept_name: + escalated_by_dept[dept_name] = escalated_by_dept.get(dept_name, 0) + 1 + cat = classify_department(dept_name) + if cat not in escalated_by_cat: + escalated_by_cat[cat] = {"escalated": 0, "total": 0} + escalated_by_cat[cat]["escalated"] += 1 + + for c in complaints_list: + dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first() + dept_name = "" + if dept_inv and dept_inv.department: + dept_name = dept_inv.department.name + elif c.department: + dept_name = c.department.name + cat = classify_department(dept_name) + if cat not in escalated_by_cat: + escalated_by_cat[cat] = {"escalated": 0, "total": 0} + escalated_by_cat[cat]["total"] += 1 + + _write_header_row(ws_escalated, row, ["Department Category", "Escalated", "Total", "Escalation Rate"]) + row += 1 + for cat, data in sorted(escalated_by_cat.items()): + rate = (data["escalated"] / data["total"] * 100) if data["total"] else 0 + ws_escalated.cell(row=row, column=1, value=cat) + ws_escalated.cell(row=row, column=2, value=data["escalated"]) + ws_escalated.cell(row=row, column=3, value=data["total"]) + ws_escalated.cell(row=row, column=4, value=f"{rate:.1f}%") + for col in range(1, 5): + ws_escalated.cell(row=row, column=col).border = THIN_BORDER + row += 1 + + row += 2 + ws_escalated.cell(row=row, column=1, value="Escalated by Specific Department").font = SECTION_FONT + row += 1 + _write_header_row(ws_escalated, row, ["Department", "Escalated Count"]) + row += 1 + for dept, cnt in sorted(escalated_by_dept.items(), key=lambda x: -x[1]): + ws_escalated.cell(row=row, column=1, value=dept) + ws_escalated.cell(row=row, column=2, value=cnt) + for col in range(1, 3): + ws_escalated.cell(row=row, column=col).border = THIN_BORDER + row += 1 + + for col in range(1, 5): + ws_escalated.column_dimensions[get_column_letter(col)].width = 30 + + response_rate_sheets = [ + ("5. Internal Response Rate", "internal"), + ("6. CHI Response Rate", "chi"), + ("7. MOH Response Rate", "moh"), + ] + for sheet_name, source_filter in response_rate_sheets: + ws_rr = wb.create_sheet(sheet_name) + row = 1 + ws_rr.cell(row=row, column=1, value=f"{title} — {sheet_name}").font = Font(bold=True, size=14) + row += 2 + + if source_filter == "internal": + filtered = [c for c in complaints_list if not (c.source and c.source.code in ("moh", "chi"))] + elif source_filter == "chi": + filtered = [c for c in complaints_list if c.source and c.source.code == "chi"] + else: + filtered = [c for c in complaints_list if c.source and c.source.code == "moh"] + + buckets = {"24 Hours": 0, "48 Hours": 0, "72 Hours": 0, "More than 72 Hours": 0} + for c in filtered: + if c.resolved_at and c.created_at: + hours = (c.resolved_at - c.created_at).total_seconds() / 3600 + if hours <= 24: + buckets["24 Hours"] += 1 + elif hours <= 48: + buckets["48 Hours"] += 1 + elif hours <= 72: + buckets["72 Hours"] += 1 + else: + buckets["More than 72 Hours"] += 1 + else: + buckets["More than 72 Hours"] += 1 + + total_filtered = len(filtered) + _write_header_row(ws_rr, row, ["Timeline", "Count", "%"]) + row += 1 + for label, cnt in buckets.items(): + pct = (cnt / total_filtered * 100) if total_filtered else 0 + ws_rr.cell(row=row, column=1, value=label) + ws_rr.cell(row=row, column=2, value=cnt) + ws_rr.cell(row=row, column=3, value=f"{pct:.1f}%") + for col in range(1, 4): + ws_rr.cell(row=row, column=col).border = THIN_BORDER + row += 1 + ws_rr.cell(row=row, column=1, value="Total").font = Font(bold=True) + ws_rr.cell(row=row, column=2, value=total_filtered).font = Font(bold=True) + + for col in range(1, 4): + ws_rr.column_dimensions[get_column_letter(col)].width = 25 + + return wb + + +def export_quarterly_calculations(queryset, year, quarter): + """ + Step 2 — Quarterly Calculations Excel export. + + Args: + queryset: Complaint queryset filtered to the quarter + year: int + quarter: int (1-4) + """ + quarter_months = { + 1: [(1, "Jan"), (2, "Feb"), (3, "Mar")], + 2: [(4, "Apr"), (5, "May"), (6, "Jun")], + 3: [(7, "Jul"), (8, "Aug"), (9, "Sep")], + 4: [(10, "Oct"), (11, "Nov"), (12, "Dec")], + } + months = quarter_months[quarter] + title = f"Q{quarter} {year}" + + wb = _build_quarterly_yearly_report(queryset, title, months, year=year) + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = f'attachment; filename="quarterly_calculations_Q{quarter}_{year}.xlsx"' + wb.save(response) + return response + + +def export_yearly_calculations(queryset, year): + """ + Yearly Calculations Excel export. + + Args: + queryset: Complaint queryset filtered to the year + year: int + """ + months = [(m, datetime.strptime(f"{m}", "%m").strftime("%b")) for m in range(1, 13)] + title = f"Yearly {year}" + + wb = _build_quarterly_yearly_report(queryset, title, months, year=year) + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = f'attachment; filename="yearly_calculations_{year}.xlsx"' + wb.save(response) + return response + + +def export_inquiries_report(queryset, year, month, is_outgoing=False): + """ + Reports — Incoming/Outgoing Inquiries Excel export. + + Matches the 27-column template from the Excel documents. + """ + wb = Workbook() + month_label = f"{year}-{month:02d}" + ws = wb.active + ws.title = month_label + + prefix = "Outgoing" if is_outgoing else "Incoming" + headers = [ + "Week", + "No.", + "Date/Time", + "Visitor Name", + "Mobile", + "Department", + "Employee ID", + "Timeline", + "Inquiry Date", + "Inquiry Time", + "Contacted NR Date", + "Contacted NR Time", + "Contacted NR Employee", + "Response Duration", + "Under Process Date", + "Under Process Time", + "Under Process Employee", + "Processing Duration", + "Contacted Date", + "Contacted Time", + "Contacted Employee", + "Contact Duration", + "", + "Inquiry Description", + "Status", + "Employee Notes", + "Supervisor Notes", + ] + _write_header(ws, 1, headers) + ws.freeze_panes = "C2" + + qs = queryset.select_related( + "hospital", + "department", + "assigned_to", + "created_by", + "outgoing_department", + "responded_by", + ).prefetch_related("updates") + + row_num = 2 + for idx, inq in enumerate(qs, 1): + week_of_month = (inq.created_at.day - 1) // 15 + 1 + week_label = "1st Half" if week_of_month == 1 else "2nd Half" + + dept_name = "" + if is_outgoing: + dept_name = inq.outgoing_department.name if inq.outgoing_department else "" + else: + dept_name = inq.department.name if inq.department else "" + + created_date = inq.created_at.date() if inq.created_at else "" + created_time = inq.created_at.strftime("%H:%M") if inq.created_at else "" + activated_date = inq.activated_at or inq.assigned_at + responded_date = inq.responded_at + + timeline = "" + if inq.due_at and inq.created_at: + hours = (inq.due_at - inq.created_at).total_seconds() / 3600 + if hours <= 24: + timeline = "24 Hours" + elif hours <= 48: + timeline = "48 Hours" + elif hours <= 72: + timeline = "72 Hours" + else: + timeline = "More than 72 hours" + + _write_row( + ws, + row_num, + [ + week_label, + idx, + f"{created_date} {created_time}", + inq.contact_name or "", + inq.contact_phone or "", + dept_name, + "", + timeline, + inq.assigned_at.date() if inq.assigned_at else "", + inq.assigned_at.strftime("%H:%M") if inq.assigned_at else "", + "", + "", + "", + "", + "", + "", + "", + "", + responded_date.date() if responded_date else "", + responded_date.strftime("%H:%M") if responded_date else "", + inq.responded_by.get_full_name() if inq.responded_by else "", + "", + "", + inq.subject, + inq.get_status_display(), + "", + "", + ], + ) + row_num += 1 + + summary_row = row_num + 2 + total = queryset.count() + status_counts = queryset.values("status").annotate(count=Count("pk")) + timeline_counts = {"24 Hours": 0, "48 Hours": 0, "72 Hours": 0, "More than 72 hours": 0} + for inq in qs: + if inq.due_at and inq.created_at: + hours = (inq.due_at - inq.created_at).total_seconds() / 3600 + if hours <= 24: + timeline_counts["24 Hours"] += 1 + elif hours <= 48: + timeline_counts["48 Hours"] += 1 + elif hours <= 72: + timeline_counts["72 Hours"] += 1 + else: + timeline_counts["More than 72 hours"] += 1 + + ws.cell(row=summary_row, column=1, value="Summary").font = SECTION_FONT + summary_row += 1 + ws.cell(row=summary_row, column=1, value=f"Total {prefix} Inquiries: {total}") + summary_row += 2 + + ws.cell(row=summary_row, column=1, value="By Status").font = SECTION_FONT + summary_row += 1 + _write_header(ws, summary_row, ["Status", "Count"]) + summary_row += 1 + for sc in status_counts: + ws.cell(row=summary_row, column=1, value=sc["status"]) + ws.cell(row=summary_row, column=2, value=sc["count"]) + summary_row += 1 + summary_row += 1 + + ws.cell(row=summary_row, column=1, value="By Timeline").font = SECTION_FONT + summary_row += 1 + _write_header(ws, summary_row, ["Timeline", "Count"]) + summary_row += 1 + for tl, cnt in timeline_counts.items(): + ws.cell(row=summary_row, column=1, value=tl) + ws.cell(row=summary_row, column=2, value=cnt) + summary_row += 1 + + for col in range(1, 10): + ws.column_dimensions[get_column_letter(col)].width = 18 + ws.column_dimensions[get_column_letter(4)].width = 25 + ws.column_dimensions[get_column_letter(23)].width = 40 + ws.column_dimensions[get_column_letter(24)].width = 20 + ws.column_dimensions[get_column_letter(26)].width = 30 + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = f'attachment; filename="{prefix.lower()}_inquiries_{month_label}.xlsx"' + wb.save(response) + return response + + +def export_observations_report(queryset, year, month): + """ + Reports — Observations Excel export. + + Matches the 22-column template from the Observations Excel document. + """ + wb = Workbook() + month_label = f"{year}-{month:02d}" + ws = wb.active + ws.title = month_label + + headers = [ + "Portal", + "Note No.", + "Send Date", + "Send Time", + "Recipient Mobile", + "File Number", + "Sender Employee ID", + "Observation Source", + "Main Category", + "Sub-Category", + "", + "Topic", + "Details (Arabic)", + "Details (English)", + "Person Notified", + "Department Notified", + "Communication Method", + "Communication Date", + "Communication Time", + "Action Plan / Action Taken", + "Follow-Up Resolved?", + "Solutions & Suggestions", + ] + _write_header(ws, 1, headers) + ws.freeze_panes = "C2" + + row_num = 2 + for idx, obs in enumerate(queryset, 1): + action_taken = "" + resolved = "" + solutions = "" + + for note in obs.notes.all(): + text = note.note.lower() if note.note else "" + if not action_taken and ("action" in text or "taken" in text): + action_taken = note.note + if "resolved" in text or "done" in text: + resolved = "Yes" + if "suggestion" in text or "solution" in text: + solutions = note.note + + obs_source = "" + if obs.source: + source_map = { + "staff_portal": "Portal", + "web_form": "Portal", + "mobile_app": "Barcode", + "email": "Referral", + "call_center": "In-person", + } + obs_source = source_map.get(obs.source, obs.source) + + status_map = { + "resolved": "done", + "closed": "resolved", + "in_progress": "under process", + "new": "", + } + resolved_display = status_map.get(obs.status, "") + + _write_row( + ws, + row_num, + [ + obs_source, + idx, + obs.incident_datetime.date() if obs.incident_datetime else "", + obs.incident_datetime.strftime("%H:%M") if obs.incident_datetime else "", + obs.reporter_phone or "", + obs.tracking_code or "", + obs.reporter_staff_id or "", + obs_source, + obs.category.name if obs.category else "", + obs.assigned_department.name if obs.assigned_department else "", + "", + obs.title or "", + obs.description or "", + obs.resolution_notes or "", + obs.assigned_to.get_full_name() if obs.assigned_to else "", + obs.assigned_department.name if obs.assigned_department else "", + "", + obs.activated_at.date() if obs.activated_at else "", + obs.activated_at.strftime("%H:%M") if obs.activated_at else "", + action_taken, + resolved_display, + solutions, + ], + ) + row_num += 1 + + for col in range(1, 23): + ws.column_dimensions[get_column_letter(col)].width = 20 + ws.column_dimensions[get_column_letter(13)].width = 40 + ws.column_dimensions[get_column_letter(14)].width = 40 + ws.column_dimensions[get_column_letter(20)].width = 35 + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = f'attachment; filename="observations_{month_label}.xlsx"' + wb.save(response) + return response diff --git a/apps/complaints/views.py b/apps/complaints/views.py index 5df12a6..06874b8 100644 --- a/apps/complaints/views.py +++ b/apps/complaints/views.py @@ -1,6 +1,7 @@ """ Complaints views and viewsets """ + import logging from django.db.models import Q @@ -19,8 +20,9 @@ from .models import ( ComplaintExplanation, ComplaintMeeting, ComplaintPRInteraction, + ComplaintStatus, ComplaintUpdate, - Inquiry + Inquiry, ) from .serializers import ( ComplaintAttachmentSerializer, @@ -31,6 +33,7 @@ from .serializers import ( ComplaintUpdateSerializer, InquirySerializer, ) +from .services.complaint_service import ComplaintService, ComplaintServiceError logger = logging.getLogger(__name__) @@ -44,58 +47,53 @@ def map_complaint_category_to_action_category(complaint_category_code): Returns 'other' as fallback if no match found. """ if not complaint_category_code: - return 'other' + return "other" mapping = { # Clinical issues - 'clinical': 'clinical_quality', - 'medical': 'clinical_quality', - 'diagnosis': 'clinical_quality', - 'treatment': 'clinical_quality', - 'medication': 'clinical_quality', - 'care': 'clinical_quality', - + "clinical": "clinical_quality", + "medical": "clinical_quality", + "diagnosis": "clinical_quality", + "treatment": "clinical_quality", + "medication": "clinical_quality", + "care": "clinical_quality", # Safety issues - 'safety': 'patient_safety', - 'risk': 'patient_safety', - 'incident': 'patient_safety', - 'infection': 'patient_safety', - 'harm': 'patient_safety', - + "safety": "patient_safety", + "risk": "patient_safety", + "incident": "patient_safety", + "infection": "patient_safety", + "harm": "patient_safety", # Service quality - 'service': 'service_quality', - 'communication': 'service_quality', - 'wait': 'service_quality', - 'response': 'service_quality', - 'customer_service': 'service_quality', - 'timeliness': 'service_quality', - 'waiting_time': 'service_quality', - + "service": "service_quality", + "communication": "service_quality", + "wait": "service_quality", + "response": "service_quality", + "customer_service": "service_quality", + "timeliness": "service_quality", + "waiting_time": "service_quality", # Staff behavior - 'staff': 'staff_behavior', - 'behavior': 'staff_behavior', - 'attitude': 'staff_behavior', - 'professionalism': 'staff_behavior', - 'rude': 'staff_behavior', - 'respect': 'staff_behavior', - + "staff": "staff_behavior", + "behavior": "staff_behavior", + "attitude": "staff_behavior", + "professionalism": "staff_behavior", + "rude": "staff_behavior", + "respect": "staff_behavior", # Facility - 'facility': 'facility', - 'environment': 'facility', - 'cleanliness': 'facility', - 'equipment': 'facility', - 'infrastructure': 'facility', - 'parking': 'facility', - 'accessibility': 'facility', - + "facility": "facility", + "environment": "facility", + "cleanliness": "facility", + "equipment": "facility", + "infrastructure": "facility", + "parking": "facility", + "accessibility": "facility", # Process - 'process': 'process_improvement', - 'administrative': 'process_improvement', - 'billing': 'process_improvement', - 'procedure': 'process_improvement', - 'workflow': 'process_improvement', - 'registration': 'process_improvement', - 'appointment': 'process_improvement', + "process": "process_improvement", + "administrative": "process_improvement", + "billing": "process_improvement", + "procedure": "process_improvement", + "workflow": "process_improvement", + "registration": "process_improvement", + "appointment": "process_improvement", } # Try exact match first @@ -109,7 +107,7 @@ def map_complaint_category_to_action_category(complaint_category_code): return action_category # Fallback to 'other' - return 'other' + return "other" class ComplaintViewSet(viewsets.ModelViewSet): @@ -120,29 +118,49 @@ class ComplaintViewSet(viewsets.ModelViewSet): - All authenticated users can view complaints - PX Admins and Hospital Admins can create/manage complaints """ + queryset = Complaint.objects.all() permission_classes = [IsAuthenticated] filterset_fields = [ - 'status', 'severity', 'priority', 'category', 'source', - 'hospital', 'department', 'staff', 'assigned_to', - 'is_overdue', 'hospital__organization' + "status", + "severity", + "priority", + "category", + "source", + "hospital", + "department", + "staff", + "assigned_to", + "is_overdue", + "hospital__organization", ] - search_fields = ['title', 'description', 'reference_number', 'patient__mrn', 'patient__first_name', 'patient__last_name'] - ordering_fields = ['created_at', 'due_at', 'severity'] - ordering = ['-created_at'] + search_fields = [ + "title", + "description", + "reference_number", + "patient__mrn", + "patient__first_name", + "patient__last_name", + ] + ordering_fields = ["created_at", "due_at", "severity"] + ordering = ["-created_at"] def get_serializer_class(self): """Use simplified serializer for list view""" - if self.action == 'list': + if self.action == "list": return ComplaintListSerializer return ComplaintSerializer def get_queryset(self): """Filter complaints based on user role""" - queryset = super().get_queryset().select_related( - 'patient', 'hospital', 'department', 'staff', - 'assigned_to', 'resolved_by', 'closed_by', 'created_by' - ).prefetch_related('attachments', 'updates') + queryset = ( + super() + .get_queryset() + .select_related( + "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", "created_by" + ) + .prefetch_related("attachments", "updates") + ) user = self.request.user @@ -151,12 +169,12 @@ class ComplaintViewSet(viewsets.ModelViewSet): return queryset # Source Users see ONLY complaints THEY created - if hasattr(user, 'source_user_profile') and user.source_user_profile.exists(): + if hasattr(user, "source_user_profile") and user.source_user_profile.exists(): return queryset.filter(created_by=user) # Patients see ONLY their own complaints (if they have user accounts) # This assumes patients can have user accounts linked via patient.user - if hasattr(user, 'patient_profile'): + if hasattr(user, "patient_profile"): return queryset.filter(patient__user=user) # Hospital Admins see complaints for their hospital @@ -179,346 +197,188 @@ class ComplaintViewSet(viewsets.ModelViewSet): for specific actions (request_explanation, resend_explanation, send_notification, assignable_admins). """ queryset = self.filter_queryset(self.get_queryset()) - + # PX Admins can access any complaint for specific actions if self.request.user.is_px_admin() and self.action in [ - 'request_explanation', 'resend_explanation', 'send_notification', 'assignable_admins', - 'escalate_explanation', 'review_explanation' + "request_explanation", + "resend_explanation", + "send_notification", + "assignable_admins", + "escalate_explanation", + "review_explanation", ]: # Bypass queryset filtering and get directly by pk lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_value = self.kwargs[lookup_url_kwarg] return get_object_or_404(Complaint, pk=lookup_value) - + # Normal behavior for other users/actions lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} obj = get_object_or_404(queryset, **filter_kwargs) - + # May raise a permission denied self.check_object_permissions(self.request, obj) return obj def perform_create(self, serializer): - """Log complaint creation and trigger resolution satisfaction survey""" - # Auto-set created_by from request.user + """Log complaint creation and trigger AI analysis""" complaint = serializer.save(created_by=self.request.user) + ComplaintService.post_create_hooks(complaint, self.request.user, request=self.request) - AuditService.log_from_request( - event_type='complaint_created', - description=f"Complaint created: {complaint.title}", - request=self.request, - content_object=complaint, - metadata={ - 'category': complaint.category, - 'severity': complaint.severity, - 'patient_mrn': complaint.patient.mrn, - 'created_by': str(complaint.created_by.id) if complaint.created_by else None - } - ) - - # Trigger AI analysis (includes PX Action auto-creation if enabled) - from apps.complaints.tasks import analyze_complaint_with_ai - analyze_complaint_with_ai.delay(str(complaint.id)) - - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def activate(self, request, pk=None): - """ - Activate complaint by assigning it to current user. - - Only PX Admins and Hospital Admins can activate complaints. - Sets assigned_to to current user, assigned_at to current time, - and status to 'in_progress'. - """ + """Activate complaint by assigning it to current user.""" complaint = self.get_object() - - # Check if user has permission to activate - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): - return Response( - {'error': 'Only PX Admins and Hospital Admins can activate complaints'}, - status=status.HTTP_403_FORBIDDEN - ) - - # Check if already assigned to current user - if complaint.assigned_to == request.user: - return Response( - {'error': 'This complaint is already assigned to you'}, - status=status.HTTP_400_BAD_REQUEST - ) - - old_assignee = complaint.assigned_to - old_status = complaint.status - - # Update complaint - complaint.assigned_to = request.user - complaint.assigned_at = timezone.now() - complaint.status = 'in_progress' - complaint.save(update_fields=['assigned_to', 'assigned_at', 'status']) - - # Create update - roles_display = ', '.join(request.user.get_role_names()) - ComplaintUpdate.objects.create( - complaint=complaint, - update_type='assignment', - message=f"Complaint activated and assigned to {request.user.get_full_name()} ({roles_display})", - created_by=request.user, - metadata={ - 'old_assignee_id': str(old_assignee.id) if old_assignee else None, - 'new_assignee_id': str(request.user.id), - 'assignee_roles': request.user.get_role_names(), - 'old_status': old_status, - 'new_status': 'in_progress', - 'activated_by_current_user': True + + try: + result = ComplaintService.activate(complaint, request.user, request=request) + except ComplaintServiceError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + { + "message": "Complaint activated successfully", + "assigned_to": { + "id": str(request.user.id), + "name": request.user.get_full_name(), + "roles": request.user.get_role_names(), + }, + "assigned_at": complaint.assigned_at.isoformat(), + "status": complaint.status, } ) - - # Log audit - AuditService.log_from_request( - event_type='complaint_activated', - description=f"Complaint activated by {request.user.get_full_name()}", - request=request, - content_object=complaint, - metadata={ - 'old_assignee_id': str(old_assignee.id) if old_assignee else None, - 'new_assignee_id': str(request.user.id), - 'old_status': old_status, - 'new_status': 'in_progress' - } - ) - - return Response({ - 'message': 'Complaint activated successfully', - 'assigned_to': { - 'id': str(request.user.id), - 'name': request.user.get_full_name(), - 'roles': request.user.get_role_names() - }, - 'assigned_at': complaint.assigned_at.isoformat(), - 'status': complaint.status - }) - - @action(detail=True, methods=['post']) + + @action(detail=True, methods=["post"]) def assign(self, request, pk=None): """Assign complaint to user (PX Admin or Hospital Admin)""" complaint = self.get_object() - user_id = request.data.get('user_id') + user_id = request.data.get("user_id") if not user_id: - return Response( - {'error': 'user_id is required'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "user_id is required"}, status=status.HTTP_400_BAD_REQUEST) from apps.accounts.models import User + try: - user = User.objects.get(id=user_id) - - # Verify user has appropriate role - if not (user.is_px_admin() or user.is_hospital_admin()): - return Response( - {'error': 'Only PX Admins and Hospital Admins can be assigned to complaints'}, - status=status.HTTP_400_BAD_REQUEST - ) - - old_assignee = complaint.assigned_to - complaint.assigned_to = user - complaint.assigned_at = timezone.now() - complaint.save(update_fields=['assigned_to', 'assigned_at']) - - # Create update - roles_display = ', '.join(user.get_role_names()) - ComplaintUpdate.objects.create( - complaint=complaint, - update_type='assignment', - message=f"Assigned to {user.get_full_name()} ({roles_display})", - created_by=request.user, - metadata={ - 'old_assignee_id': str(old_assignee.id) if old_assignee else None, - 'new_assignee_id': str(user.id), - 'assignee_roles': user.get_role_names() - } - ) - - AuditService.log_from_request( - event_type='assignment', - description=f"Complaint assigned to {user.get_full_name()} ({roles_display})", - request=request, - content_object=complaint, - metadata={ - 'old_assignee_id': str(old_assignee.id) if old_assignee else None, - 'new_assignee_id': str(user.id) - } - ) - - return Response({'message': 'Complaint assigned successfully'}) + target_user = User.objects.get(id=user_id) + ComplaintService.assign(complaint, target_user, request.user, request=request) except User.DoesNotExist: - return Response( - {'error': 'User not found'}, - status=status.HTTP_404_NOT_FOUND - ) - - @action(detail=True, methods=['get']) + return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND) + except ComplaintServiceError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"message": "Complaint assigned successfully"}) + + @action(detail=True, methods=["get"]) def assignable_admins(self, request, pk=None): """ Get assignable admins (PX Admins and Hospital Admins) for this complaint. - + Returns list of all PX Admins and Hospital Admins. Supports searching by name. """ complaint = self.get_object() - + # Check if user has permission to assign admins if not request.user.is_px_admin(): return Response( - {'error': 'Only PX Admins can assign complaints to admins'}, - status=status.HTTP_403_FORBIDDEN + {"error": "Only PX Admins can assign complaints to admins"}, status=status.HTTP_403_FORBIDDEN ) - + from apps.accounts.models import User - + # Get search parameter - search = request.query_params.get('search', '').strip() - + search = request.query_params.get("search", "").strip() + # Simple query - get all PX Admins and Hospital Admins - base_query = Q(groups__name='PX Admin') | Q(groups__name='Hospital Admin') - - queryset = User.objects.filter( - base_query, - is_active=True - ).select_related('hospital').prefetch_related('groups').order_by('first_name', 'last_name') - + base_query = Q(groups__name="PX Admin") | Q(groups__name="Hospital Admin") + + queryset = ( + User.objects.filter(base_query, is_active=True) + .select_related("hospital") + .prefetch_related("groups") + .order_by("first_name", "last_name") + ) + # Search by name or email if provided if search: queryset = queryset.filter( - Q(first_name__icontains=search) | - Q(last_name__icontains=search) | - Q(email__icontains=search) + Q(first_name__icontains=search) | Q(last_name__icontains=search) | Q(email__icontains=search) ) - + # Serialize admins_list = [] for user in queryset: roles = user.get_role_names() - role_display = ', '.join(roles) - - admins_list.append({ - 'id': str(user.id), - 'name': user.get_full_name(), - 'email': user.email, - 'roles': roles, - 'role_display': role_display, - 'hospital': user.hospital.name if user.hospital else None, - 'is_px_admin': user.is_px_admin(), - 'is_hospital_admin': user.is_hospital_admin() - }) - - return Response({ - 'complaint_id': str(complaint.id), - 'hospital_id': str(complaint.hospital.id), - 'hospital_name': complaint.hospital.name, - 'current_assignee': { - 'id': str(complaint.assigned_to.id), - 'name': complaint.assigned_to.get_full_name(), - 'email': complaint.assigned_to.email, - 'roles': complaint.assigned_to.get_role_names() - } if complaint.assigned_to else None, - 'admin_count': len(admins_list), - 'admins': admins_list - }) + role_display = ", ".join(roles) - @action(detail=True, methods=['post']) + admins_list.append( + { + "id": str(user.id), + "name": user.get_full_name(), + "email": user.email, + "roles": roles, + "role_display": role_display, + "hospital": user.hospital.name if user.hospital else None, + "is_px_admin": user.is_px_admin(), + "is_hospital_admin": user.is_hospital_admin(), + } + ) + + return Response( + { + "complaint_id": str(complaint.id), + "hospital_id": str(complaint.hospital.id), + "hospital_name": complaint.hospital.name, + "current_assignee": { + "id": str(complaint.assigned_to.id), + "name": complaint.assigned_to.get_full_name(), + "email": complaint.assigned_to.email, + "roles": complaint.assigned_to.get_role_names(), + } + if complaint.assigned_to + else None, + "admin_count": len(admins_list), + "admins": admins_list, + } + ) + + @action(detail=True, methods=["post"]) def change_status(self, request, pk=None): """Change complaint status""" complaint = self.get_object() - new_status = request.data.get('status') - note = request.data.get('note', '') - - # Resolution-specific fields - resolution_text = request.data.get('resolution', '') - resolution_category = request.data.get('resolution_category', '') - if not new_status: - return Response( - {'error': 'status is required'}, - status=status.HTTP_400_BAD_REQUEST + try: + ComplaintService.change_status( + complaint, + request.data.get("status", ""), + request.user, + request=request, + note=request.data.get("note", ""), + resolution=request.data.get("resolution", ""), + resolution_category=request.data.get("resolution_category", ""), ) + except ComplaintServiceError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - old_status = complaint.status - complaint.status = new_status + return Response({"message": "Status updated successfully"}) - # Handle status-specific logic - if new_status == 'resolved': - complaint.resolved_at = timezone.now() - complaint.resolved_by = request.user - - # Update resolution fields if provided - if resolution_text: - complaint.resolution = resolution_text - if resolution_category: - complaint.resolution_category = resolution_category - - elif new_status == 'closed': - complaint.closed_at = timezone.now() - complaint.closed_by = request.user - - # Trigger resolution satisfaction survey - from apps.complaints.tasks import send_complaint_resolution_survey - send_complaint_resolution_survey.delay(str(complaint.id)) - - complaint.save() - - # Create update - ComplaintUpdate.objects.create( - complaint=complaint, - update_type='status_change', - message=note or f"Status changed from {old_status} to {new_status}", - created_by=request.user, - old_status=old_status, - new_status=new_status, - metadata={ - 'resolution_text': resolution_text if resolution_text else None, - 'resolution_category': resolution_category if resolution_category else None - } - ) - - AuditService.log_from_request( - event_type='status_change', - description=f"Complaint status changed from {old_status} to {new_status}", - request=request, - content_object=complaint, - metadata={ - 'old_status': old_status, - 'new_status': new_status, - 'resolution_category': resolution_category if resolution_category else None - } - ) - - return Response({'message': 'Status updated successfully'}) - - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def add_note(self, request, pk=None): """Add note to complaint""" complaint = self.get_object() - note = request.data.get('note') + note = request.data.get("note") - if not note: - return Response( - {'error': 'note is required'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Create update - update = ComplaintUpdate.objects.create( - complaint=complaint, - update_type='note', - message=note, - created_by=request.user - ) + try: + update = ComplaintService.add_note(complaint, note, request.user, request=request) + except ComplaintServiceError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) serializer = ComplaintUpdateSerializer(update) return Response(serializer.data, status=status.HTTP_201_CREATED) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def staff_suggestions(self, request, pk=None): """ Get staff matching suggestions for a complaint. @@ -530,27 +390,26 @@ class ComplaintViewSet(viewsets.ModelViewSet): # Check if user is PX Admin if not request.user.is_px_admin(): - return Response( - {'error': 'Only PX Admins can access staff suggestions'}, - status=status.HTTP_403_FORBIDDEN - ) + return Response({"error": "Only PX Admins can access staff suggestions"}, status=status.HTTP_403_FORBIDDEN) # Get AI analysis metadata - ai_analysis = complaint.metadata.get('ai_analysis', {}) - staff_matches = ai_analysis.get('staff_matches', []) - extracted_name = ai_analysis.get('extracted_staff_name', '') - needs_review = ai_analysis.get('needs_staff_review', False) - matched_staff_id = ai_analysis.get('matched_staff_id') + ai_analysis = complaint.metadata.get("ai_analysis", {}) + staff_matches = ai_analysis.get("staff_matches", []) + extracted_name = ai_analysis.get("extracted_staff_name", "") + needs_review = ai_analysis.get("needs_staff_review", False) + matched_staff_id = ai_analysis.get("matched_staff_id") - return Response({ - 'extracted_name': extracted_name, - 'staff_matches': staff_matches, - 'current_staff_id': matched_staff_id, - 'needs_staff_review': needs_staff_review, - 'staff_match_count': len(staff_matches) - }) + return Response( + { + "extracted_name": extracted_name, + "staff_matches": staff_matches, + "current_staff_id": matched_staff_id, + "needs_staff_review": needs_staff_review, + "staff_match_count": len(staff_matches), + } + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def hospital_staff(self, request, pk=None): """ Get all staff from complaint's hospital for manual selection. @@ -563,21 +422,17 @@ class ComplaintViewSet(viewsets.ModelViewSet): # Check if user is PX Admin if not request.user.is_px_admin(): return Response( - {'error': 'Only PX Admins can access hospital staff list'}, - status=status.HTTP_403_FORBIDDEN + {"error": "Only PX Admins can access hospital staff list"}, status=status.HTTP_403_FORBIDDEN ) from apps.organizations.models import Staff # Get query params - department_id = request.query_params.get('department_id') - search = request.query_params.get('search', '').strip() + department_id = request.query_params.get("department_id") + search = request.query_params.get("search", "").strip() # Build query - queryset = Staff.objects.filter( - hospital=complaint.hospital, - status='active' - ).select_related('department') + queryset = Staff.objects.filter(hospital=complaint.hospital, status="active").select_related("department") # Filter by department if specified if department_id: @@ -586,37 +441,43 @@ class ComplaintViewSet(viewsets.ModelViewSet): # Search by name if provided if search: queryset = queryset.filter( - Q(first_name__icontains=search) | - Q(last_name__icontains=search) | - Q(first_name_ar__icontains=search) | - Q(last_name_ar__icontains=search) | - Q(job_title__icontains=search) + Q(first_name__icontains=search) + | Q(last_name__icontains=search) + | Q(first_name_ar__icontains=search) + | Q(last_name_ar__icontains=search) + | Q(job_title__icontains=search) ) # Order by department and name - queryset = queryset.order_by('department__name', 'first_name', 'last_name') + queryset = queryset.order_by("department__name", "first_name", "last_name") # Serialize staff_list = [] for staff in queryset: - staff_list.append({ - 'id': str(staff.id), - 'name_en': f"{staff.first_name} {staff.last_name}", - 'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "", - 'job_title': staff.job_title, - 'specialization': staff.specialization, - 'department': staff.department.name if staff.department else None, - 'department_id': str(staff.department.id) if staff.department else None - }) + staff_list.append( + { + "id": str(staff.id), + "name_en": f"{staff.first_name} {staff.last_name}", + "name_ar": f"{staff.first_name_ar} {staff.last_name_ar}" + if staff.first_name_ar and staff.last_name_ar + else "", + "job_title": staff.job_title, + "specialization": staff.specialization, + "department": staff.department.name if staff.department else None, + "department_id": str(staff.department.id) if staff.department else None, + } + ) - return Response({ - 'hospital_id': str(complaint.hospital.id), - 'hospital_name': complaint.hospital.name, - 'staff_count': len(staff_list), - 'staff': staff_list - }) + return Response( + { + "hospital_id": str(complaint.hospital.id), + "hospital_name": complaint.hospital.name, + "staff_count": len(staff_list), + "staff": staff_list, + } + ) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def assign_staff(self, request, pk=None): """ Manually assign staff to a complaint. @@ -629,40 +490,35 @@ class ComplaintViewSet(viewsets.ModelViewSet): # Check if complaint is in active status if not complaint.is_active_status: return Response( - {'error': f"Cannot assign staff to complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."}, - status=status.HTTP_400_BAD_REQUEST + { + "error": f"Cannot assign staff to complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved." + }, + status=status.HTTP_400_BAD_REQUEST, ) # Check if user is PX Admin if not request.user.is_px_admin(): return Response( - {'error': 'Only PX Admins can assign staff to complaints'}, - status=status.HTTP_403_FORBIDDEN + {"error": "Only PX Admins can assign staff to complaints"}, status=status.HTTP_403_FORBIDDEN ) - staff_id = request.data.get('staff_id') - reason = request.data.get('reason', '') + staff_id = request.data.get("staff_id") + reason = request.data.get("reason", "") if not staff_id: - return Response( - {'error': 'staff_id is required'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "staff_id is required"}, status=status.HTTP_400_BAD_REQUEST) from apps.organizations.models import Staff + try: staff = Staff.objects.get(id=staff_id) except Staff.DoesNotExist: - return Response( - {'error': 'Staff not found'}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Staff not found"}, status=status.HTTP_404_NOT_FOUND) # Check staff belongs to same hospital if staff.hospital != complaint.hospital: return Response( - {'error': 'Staff does not belong to complaint hospital'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Staff does not belong to complaint hospital"}, status=status.HTTP_400_BAD_REQUEST ) # Update complaint @@ -670,142 +526,99 @@ class ComplaintViewSet(viewsets.ModelViewSet): complaint.staff = staff # Auto-set department from staff complaint.department = staff.department - complaint.save(update_fields=['staff', 'department']) + complaint.save(update_fields=["staff", "department"]) # Update metadata to clear review flag if not complaint.metadata: complaint.metadata = {} - if 'ai_analysis' in complaint.metadata: - complaint.metadata['ai_analysis']['needs_staff_review'] = False - complaint.metadata['ai_analysis']['staff_manually_assigned'] = True - complaint.metadata['ai_analysis']['staff_assigned_by'] = str(request.user.id) - complaint.metadata['ai_analysis']['staff_assigned_at'] = timezone.now().isoformat() - complaint.metadata['ai_analysis']['staff_assignment_reason'] = reason - complaint.save(update_fields=['metadata']) + if "ai_analysis" in complaint.metadata: + complaint.metadata["ai_analysis"]["needs_staff_review"] = False + complaint.metadata["ai_analysis"]["staff_manually_assigned"] = True + complaint.metadata["ai_analysis"]["staff_assigned_by"] = str(request.user.id) + complaint.metadata["ai_analysis"]["staff_assigned_at"] = timezone.now().isoformat() + complaint.metadata["ai_analysis"]["staff_assignment_reason"] = reason + complaint.save(update_fields=["metadata"]) # Create update ComplaintUpdate.objects.create( complaint=complaint, - update_type='assignment', - message=f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title}). {reason}" if reason else f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title})", + update_type="assignment", + message=f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title}). {reason}" + if reason + else f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title})", created_by=request.user, - metadata={ - 'old_staff_id': old_staff_id, - 'new_staff_id': str(staff.id), - 'manual_assignment': True - } + metadata={"old_staff_id": old_staff_id, "new_staff_id": str(staff.id), "manual_assignment": True}, ) # Log audit AuditService.log_from_request( - event_type='staff_assigned', + event_type="staff_assigned", description=f"Staff {staff.first_name} {staff.last_name} manually assigned to complaint by {request.user.get_full_name()}", request=request, content_object=complaint, - metadata={ - 'old_staff_id': old_staff_id, - 'new_staff_id': str(staff.id), - 'reason': reason + metadata={"old_staff_id": old_staff_id, "new_staff_id": str(staff.id), "reason": reason}, + ) + + return Response( + { + "message": "Staff assigned successfully", + "staff_id": str(staff.id), + "staff_name": f"{staff.first_name} {staff.last_name}", } ) - return Response({ - 'message': 'Staff assigned successfully', - 'staff_id': str(staff.id), - 'staff_name': f"{staff.first_name} {staff.last_name}" - }) - - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def change_department(self, request, pk=None): """Change complaint department""" complaint = self.get_object() - department_id = request.data.get('department_id') + department_id = request.data.get("department_id") if not department_id: - return Response( - {'error': 'department_id is required'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "department_id is required"}, status=status.HTTP_400_BAD_REQUEST) from apps.organizations.models import Department + try: department = Department.objects.get(id=department_id) + ComplaintService.change_department(complaint, department, request.user, request=request) except Department.DoesNotExist: - return Response( - {'error': 'Department not found'}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Department not found"}, status=status.HTTP_404_NOT_FOUND) + except ComplaintServiceError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - # Check department belongs to same hospital - if department.hospital != complaint.hospital: - return Response( - {'error': 'Department does not belong to complaint hospital'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Update complaint - old_department_id = str(complaint.department.id) if complaint.department else None - complaint.department = department - complaint.save(update_fields=['department']) - - # Create update - ComplaintUpdate.objects.create( - complaint=complaint, - update_type='assignment', - message=f"Department changed to {department.name}", - created_by=request.user, - metadata={ - 'old_department_id': old_department_id, - 'new_department_id': str(department.id) + return Response( + { + "message": "Department changed successfully", + "department_id": str(department.id), + "department_name": department.name, } ) - # Log audit - AuditService.log_from_request( - event_type='department_change', - description=f"Complaint department changed to {department.name}", - request=request, - content_object=complaint, - metadata={ - 'old_department_id': old_department_id, - 'new_department_id': str(department.id) - } - ) - - return Response({ - 'message': 'Department changed successfully', - 'department_id': str(department.id), - 'department_name': department.name - }) - - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def create_action_from_ai(self, request, pk=None): """Create PX Action using AI service to generate action details from complaint""" complaint = self.get_object() # Use AI service to generate action data from apps.core.ai_service import AIService - + try: action_data = AIService.create_px_action_from_complaint(complaint) except Exception as e: return Response( - {'error': f'Failed to generate action data: {str(e)}'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + {"error": f"Failed to generate action data: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # Get optional assigned_to from request (AI doesn't assign by default) - assigned_to_id = request.data.get('assigned_to') + assigned_to_id = request.data.get("assigned_to") assigned_to = None if assigned_to_id: from apps.accounts.models import User + try: assigned_to = User.objects.get(id=assigned_to_id) except User.DoesNotExist: - return Response( - {'error': 'Assigned user not found'}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Assigned user not found"}, status=status.HTTP_404_NOT_FOUND) # Create PX Action from apps.px_action_center.models import PXAction, PXActionLog @@ -814,78 +627,81 @@ class ComplaintViewSet(viewsets.ModelViewSet): complaint_content_type = ContentType.objects.get_for_model(Complaint) action = PXAction.objects.create( - source_type='complaint', + source_type="complaint", content_type=complaint_content_type, object_id=complaint.id, - title=action_data['title'], - description=action_data['description'], + title=action_data["title"], + description=action_data["description"], hospital=complaint.hospital, department=complaint.department, - category=action_data['category'], - priority=action_data['priority'], - severity=action_data['severity'], + category=action_data["category"], + priority=action_data["priority"], + severity=action_data["severity"], assigned_to=assigned_to, - status='open', + status="open", metadata={ - 'source_complaint_id': str(complaint.id), - 'source_complaint_title': complaint.title, - 'ai_generated': True, - 'ai_reasoning': action_data.get('reasoning', ''), - 'created_from_ai_suggestion': True - } + "source_complaint_id": str(complaint.id), + "source_complaint_title": complaint.title, + "ai_generated": True, + "ai_reasoning": action_data.get("reasoning", ""), + "created_from_ai_suggestion": True, + }, ) # Create action log entry PXActionLog.objects.create( action=action, - log_type='note', + log_type="note", message=f"Action generated by AI for complaint: {complaint.title}", created_by=request.user, metadata={ - 'complaint_id': str(complaint.id), - 'ai_generated': True, - 'category': action_data['category'], - 'priority': action_data['priority'], - 'severity': action_data['severity'] - } + "complaint_id": str(complaint.id), + "ai_generated": True, + "category": action_data["category"], + "priority": action_data["priority"], + "severity": action_data["severity"], + }, ) # Create complaint update ComplaintUpdate.objects.create( complaint=complaint, - update_type='note', + update_type="note", message=f"PX Action created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}", created_by=request.user, - metadata={'action_id': str(action.id), 'category': action_data['category']} + metadata={"action_id": str(action.id), "category": action_data["category"]}, ) # Log audit AuditService.log_from_request( - event_type='action_created_from_ai', + event_type="action_created_from_ai", description=f"PX Action created from AI analysis for complaint: {complaint.title}", request=request, content_object=action, metadata={ - 'complaint_id': str(complaint.id), - 'category': action_data['category'], - 'priority': action_data['priority'], - 'severity': action_data['severity'], - 'ai_reasoning': action_data.get('reasoning', '') - } + "complaint_id": str(complaint.id), + "category": action_data["category"], + "priority": action_data["priority"], + "severity": action_data["severity"], + "ai_reasoning": action_data.get("reasoning", ""), + }, ) - return Response({ - 'action_id': str(action.id), - 'message': 'Action created successfully from AI analysis', - 'action_data': { - 'title': action_data['title'], - 'category': action_data['category'], - 'priority': action_data['priority'], - 'severity': action_data['severity'] - } - }, status=status.HTTP_201_CREATED) + return Response( + { + "action_id": str(action.id), + "message": "Action created successfully from AI analysis", + "action_data": { + "title": action_data["title"], + "category": action_data["category"], + "priority": action_data["priority"], + "severity": action_data["severity"], + }, + }, + status=status.HTTP_201_CREATED, + ) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def send_notification(self, request, pk=None): """ Send email notification to staff member or department head. @@ -901,15 +717,12 @@ class ComplaintViewSet(viewsets.ModelViewSet): complaint = self.get_object() # Get email message (required) - email_message = request.data.get('email_message', '').strip() + email_message = request.data.get("email_message", "").strip() if not email_message: - return Response( - {'error': 'email_message is required'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "email_message is required"}, status=status.HTTP_400_BAD_REQUEST) # Get additional message (optional) - additional_message = request.data.get('additional_message', '').strip() + additional_message = request.data.get("additional_message", "").strip() # Determine recipient with priority logic recipient = None @@ -921,27 +734,29 @@ class ComplaintViewSet(viewsets.ModelViewSet): if complaint.staff and complaint.staff.user: recipient = complaint.staff.user recipient_display = str(complaint.staff) - recipient_type = 'Staff Member (User Account)' + recipient_type = "Staff Member (User Account)" recipient_email = recipient.email # Priority 2: Staff member with email field (no user account) elif complaint.staff and complaint.staff.email: recipient_display = str(complaint.staff) - recipient_type = 'Staff Member (Email)' + recipient_type = "Staff Member (Email)" recipient_email = complaint.staff.email # Priority 3: Department head elif complaint.department and complaint.department.manager: recipient = complaint.department.manager recipient_display = recipient.get_full_name() - recipient_type = 'Department Head' + recipient_type = "Department Head" recipient_email = recipient.email # Check if we found a recipient with email if not recipient_email: return Response( - {'error': 'No valid recipient found. Complaint must have staff with email, or a department manager with email.'}, - status=status.HTTP_400_BAD_REQUEST + { + "error": "No valid recipient found. Complaint must have staff with email, or a department manager with email." + }, + status=status.HTTP_400_BAD_REQUEST, ) # Construct email content @@ -987,6 +802,7 @@ ADDITIONAL MESSAGE: # Add link to complaint from django.contrib.sites.shortcuts import get_current_site + site = get_current_site(request) complaint_url = f"https://{site.domain}/complaints/{complaint.id}/" @@ -1011,389 +827,147 @@ This is an automated message from PX360 Complaint Management System. message=email_body, related_object=complaint, metadata={ - 'notification_type': 'complaint_notification', - 'recipient_type': recipient_type, - 'recipient_id': str(recipient.id) if recipient else None, - 'sender_id': str(request.user.id), - 'has_additional_message': bool(additional_message) - } + "notification_type": "complaint_notification", + "recipient_type": recipient_type, + "recipient_id": str(recipient.id) if recipient else None, + "sender_id": str(request.user.id), + "has_additional_message": bool(additional_message), + }, ) except Exception as e: - return Response( - {'error': f'Failed to send email: {str(e)}'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, - update_type='communication', + update_type="communication", message=f"Email notification sent to {recipient_type}: {recipient_display}", created_by=request.user, metadata={ - 'recipient_type': recipient_type, - 'recipient_id': str(recipient.id) if recipient else None, - 'notification_log_id': str(notification_log.id) if notification_log else None - } + "recipient_type": recipient_type, + "recipient_id": str(recipient.id) if recipient else None, + "notification_log_id": str(notification_log.id) if notification_log else None, + }, ) # Log audit AuditService.log_from_request( - event_type='notification_sent', + event_type="notification_sent", description=f"Email notification sent to {recipient_type}: {recipient_display}", request=request, content_object=complaint, metadata={ - 'recipient_type': recipient_type, - 'recipient_id': str(recipient.id) if recipient else None, - 'recipient_email': recipient_email + "recipient_type": recipient_type, + "recipient_id": str(recipient.id) if recipient else None, + "recipient_email": recipient_email, + }, + ) + + return Response( + { + "success": True, + "message": "Email notification sent successfully", + "recipient": recipient_display, + "recipient_type": recipient_type, + "recipient_email": recipient_email, } ) - return Response({ - 'success': True, - 'message': 'Email notification sent successfully', - 'recipient': recipient_display, - 'recipient_type': recipient_type, - 'recipient_email': recipient_email - }) - - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def request_explanation(self, request, pk=None): """ - Request explanation from staff/recipient. - - Sends explanation link to staff member, and informational notification to manager. - Manager only gets a link if/when the request escalates due to SLA breach. + Request explanation from complaint staff. + Delegates to ComplaintService for shared logic. """ complaint = self.get_object() - # Check if complaint is in active status if not complaint.is_active_status: return Response( - {'error': f"Cannot request explanation for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."}, - status=status.HTTP_400_BAD_REQUEST + {"error": f"Cannot request explanation for complaint with status '{complaint.get_status_display()}'."}, + status=status.HTTP_400_BAD_REQUEST, ) - # Check if complaint has staff to request explanation from if not complaint.staff: return Response( - {'error': 'Complaint has no staff assigned to request explanation from'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Complaint has no staff assigned to request explanation from"}, + status=status.HTTP_400_BAD_REQUEST, ) - # Check if explanation already exists for this staff - from .models import ComplaintExplanation - existing_explanation = ComplaintExplanation.objects.filter( - complaint=complaint, - staff=complaint.staff - ).first() - - if existing_explanation and existing_explanation.is_used: + existing = ComplaintExplanation.objects.filter(complaint=complaint, staff=complaint.staff).first() + if existing and existing.is_used: return Response( - {'error': 'This staff member has already submitted an explanation'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "This staff member has already submitted an explanation"}, + status=status.HTTP_400_BAD_REQUEST, ) - # Get optional message - request_message = request.data.get('request_message', '').strip() + staff = complaint.staff + staff_email = staff.user.email if staff.user and staff.user.email else (staff.email or "") - # Get manager (report_to) if exists - manager = complaint.staff.report_to if complaint.staff.report_to else None + if not staff_email: + return Response({"error": "Staff member has no email address"}, status=status.HTTP_400_BAD_REQUEST) + + request_message = request.data.get("request_message", "").strip() + manager = staff.report_to if staff.report_to else None + manager_email = ( + manager.user.email + if manager and manager.user and manager.user.email + else (manager.email if manager else "") + ) from django.contrib.sites.shortcuts import get_current_site - from apps.notifications.services import NotificationService site = get_current_site(request) + domain = site.domain - results = [] + staff_list = [ + { + "staff": staff, + "staff_id": str(staff.id), + "staff_email": staff_email, + "staff_name": str(staff), + "department": staff.department.name if staff.department else "", + "role": "Primary", + "manager": manager, + "manager_id": str(manager.id) if manager else None, + "manager_email": manager_email, + "manager_name": str(manager) if manager else None, + } + ] - # === SEND TO STAFF MEMBER (with link) === - # Generate unique token for staff - import secrets - staff_token = secrets.token_urlsafe(32) + selected_staff_ids = [str(staff.id)] + selected_manager_ids = [str(manager.id)] if manager else [] - # Create or update explanation record for staff - if existing_explanation: - staff_explanation = existing_explanation - staff_explanation.token = staff_token - staff_explanation.is_used = False - staff_explanation.requested_by = request.user - staff_explanation.request_message = request_message - staff_explanation.email_sent_at = timezone.now() - staff_explanation.save() - else: - staff_explanation = ComplaintExplanation.objects.create( - complaint=complaint, - staff=complaint.staff, - token=staff_token, - is_used=False, - submitted_via='email_link', - requested_by=request.user, - request_message=request_message, - email_sent_at=timezone.now() - ) - - # Determine staff email - if complaint.staff.user and complaint.staff.user.email: - staff_email = complaint.staff.user.email - staff_display = str(complaint.staff) - elif complaint.staff.email: - staff_email = complaint.staff.email - staff_display = str(complaint.staff) - else: - return Response( - {'error': 'Staff member has no email address'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Build staff email - staff_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{staff_token}/" - staff_subject = f"Explanation Request - Complaint #{complaint.id}" - - staff_email_body = f""" -Dear {staff_display}, - -We have received a complaint that requires your explanation. - -COMPLAINT DETAILS: ----------------- -Reference: #{complaint.id} -Title: {complaint.title} -Severity: {complaint.get_severity_display()} -Priority: {complaint.get_priority_display()} - -{complaint.description} - -""" - - # Add patient info if available - if complaint.patient: - staff_email_body += f""" -PATIENT INFORMATION: ------------------- -Name: {complaint.patient.get_full_name()} -MRN: {complaint.patient.mrn} -""" - - # Add request message if provided - if request_message: - staff_email_body += f""" - -ADDITIONAL MESSAGE: ------------------- -{request_message} -""" - - staff_email_body += f""" - -SUBMIT YOUR EXPLANATION: ------------------------- -Your perspective is important. Please submit your explanation about this complaint: -{staff_link} - -Note: This link can only be used once. After submission, it will expire. - -If you have any questions, please contact the PX team. - ---- -This is an automated message from PX360 Complaint Management System. -""" - - # Send email to staff try: - staff_notification = NotificationService.send_email( - email=staff_email, - subject=staff_subject, - message=staff_email_body, - related_object=complaint, - metadata={ - 'notification_type': 'explanation_request', - 'recipient_type': 'staff', - 'staff_id': str(complaint.staff.id), - 'explanation_id': str(staff_explanation.id), - 'requested_by_id': str(request.user.id), - 'has_request_message': bool(request_message) - } + result = ComplaintService.request_explanation( + complaint, + staff_list, + selected_staff_ids, + selected_manager_ids, + request_message, + request.user, + domain, + request=request, ) - results.append({ - 'recipient_type': 'staff', - 'recipient': staff_display, - 'email': staff_email, - 'explanation_id': str(staff_explanation.id), - 'sent': True - }) - except Exception as e: - results.append({ - 'recipient_type': 'staff', - 'recipient': staff_display, - 'email': staff_email, - 'sent': False, - 'error': str(e) - }) + except ComplaintServiceError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - # === SEND NOTIFICATION TO MANAGER (informational only - no link) === - manager_notified = False - if manager: - # Determine manager email - if manager.user and manager.user.email: - manager_email = manager.user.email - elif manager.email: - manager_email = manager.email - else: - manager_email = None + staff_explanation = ComplaintExplanation.objects.filter(complaint=complaint, staff=staff).first() - if manager_email: - manager_display = str(manager) - manager_subject = f"Staff Explanation Request Notification - Complaint #{complaint.id}" - - manager_email_body = f""" -Dear {manager_display}, - -This is an informational notification that an explanation has been requested from your team member. - -STAFF MEMBER: {staff_display} - -COMPLAINT DETAILS: ----------------- -Reference: #{complaint.id} -Title: {complaint.title} -Severity: {complaint.get_severity_display()} -Priority: {complaint.get_priority_display()} - -{complaint.description} - -""" - - # Add patient info if available - if complaint.patient: - manager_email_body += f""" -PATIENT INFORMATION: ------------------- -Name: {complaint.patient.get_full_name()} -MRN: {complaint.patient.mrn} -""" - - # Add request message if provided - if request_message: - manager_email_body += f""" - -ADDITIONAL MESSAGE: ------------------- -{request_message} -""" - - manager_email_body += f""" - -ACTION REQUIRED: ----------------- -An explanation link has been sent directly to {staff_display}. -If no response is received within the SLA deadline, you will receive a follow-up request with a link to provide your perspective as the manager. - -If you have any questions, please contact the PX team. - ---- -This is an automated message from PX360 Complaint Management System. -""" - - # Send informational email to manager - try: - manager_notification = NotificationService.send_email( - email=manager_email, - subject=manager_subject, - message=manager_email_body, - related_object=complaint, - metadata={ - 'notification_type': 'explanation_request_notification', - 'recipient_type': 'manager', - 'staff_id': str(manager.id), - 'related_staff_id': str(complaint.staff.id), - 'requested_by_id': str(request.user.id), - 'has_request_message': bool(request_message), - 'informational_only': True - } - ) - results.append({ - 'recipient_type': 'manager', - 'recipient': manager_display, - 'email': manager_email, - 'sent': True, - 'informational_only': True, - 'note': 'Manager will receive link if staff does not respond within SLA' - }) - manager_notified = True - except Exception as e: - results.append({ - 'recipient_type': 'manager', - 'recipient': manager_display, - 'email': manager_email, - 'sent': False, - 'informational_only': True, - 'error': str(e) - }) - else: - results.append({ - 'recipient_type': 'manager', - 'recipient': str(manager), - 'sent': False, - 'informational_only': True, - 'error': 'Manager has no email address' - }) - - # Create ComplaintUpdate entry - recipients_str = ", ".join([r['recipient'] for r in results if r['sent']]) - ComplaintUpdate.objects.create( - complaint=complaint, - update_type='communication', - message=f"Explanation request sent to: {recipients_str}", - created_by=request.user, - metadata={ - 'explanation_id': str(staff_explanation.id), - 'staff_id': str(complaint.staff.id), - 'manager_id': str(manager.id) if manager else None, - 'manager_notified': manager_notified, - 'results': results + return Response( + { + "success": result["staff_count"] > 0, + "message": "Explanation request sent successfully", + "results": result["results"], + "staff_explanation_id": str(staff_explanation.id) if staff_explanation else None, + "manager_notified": result["manager_count"] > 0, } ) - # Log audit - AuditService.log_from_request( - event_type='explanation_requested', - description=f"Explanation request sent to: {recipients_str}", - request=request, - content_object=complaint, - metadata={ - 'explanation_id': str(staff_explanation.id), - 'staff_id': str(complaint.staff.id), - 'manager_id': str(manager.id) if manager else None, - 'manager_notified': manager_notified, - 'request_message': request_message, - 'results': results - } - ) - - # Check if at least staff email was sent - staff_sent = any(r['recipient_type'] == 'staff' and r['sent'] for r in results) - if not staff_sent: - return Response({ - 'success': False, - 'message': 'Failed to send explanation request to staff member', - 'results': results - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - return Response({ - 'success': True, - 'message': 'Explanation request sent successfully', - 'results': results, - 'staff_explanation_id': str(staff_explanation.id), - 'manager_notified': manager_notified - }) - - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def resend_explanation(self, request, pk=None): """ Resend explanation request email to staff member only. - + Regenerates the token with a new value and resends the email to the staff member. Manager is not resent the informational email - they already received it initially. Only allows resending if explanation has not been submitted yet. @@ -1403,44 +977,43 @@ This is an automated message from PX360 Complaint Management System. # Check if complaint is in active status if not complaint.is_active_status: return Response( - {'error': f"Cannot resend explanation for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."}, - status=status.HTTP_400_BAD_REQUEST + { + "error": f"Cannot resend explanation for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved." + }, + status=status.HTTP_400_BAD_REQUEST, ) - + # Check if complaint has staff assigned if not complaint.staff: - return Response( - {'error': 'No staff assigned to this complaint'}, - status=status.HTTP_400_BAD_REQUEST - ) - + return Response({"error": "No staff assigned to this complaint"}, status=status.HTTP_400_BAD_REQUEST) + # Check if explanation exists for this staff from .models import ComplaintExplanation + try: - explanation = ComplaintExplanation.objects.filter( - complaint=complaint, - staff=complaint.staff - ).latest('created_at') + explanation = ComplaintExplanation.objects.filter(complaint=complaint, staff=complaint.staff).latest( + "created_at" + ) except ComplaintExplanation.DoesNotExist: return Response( - {'error': 'No explanation found for this complaint and staff'}, - status=status.HTTP_404_NOT_FOUND + {"error": "No explanation found for this complaint and staff"}, status=status.HTTP_404_NOT_FOUND ) - + # Check if already submitted (can only resend if not submitted) if explanation.is_used: return Response( - {'error': 'Explanation already submitted, cannot resend. Create a new explanation request.'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Explanation already submitted, cannot resend. Create a new explanation request."}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Generate new token import secrets + new_token = secrets.token_urlsafe(32) explanation.token = new_token explanation.email_sent_at = timezone.now() explanation.save() - + # Determine recipient email if complaint.staff.user and complaint.staff.user.email: recipient_email = complaint.staff.user.email @@ -1449,21 +1022,18 @@ This is an automated message from PX360 Complaint Management System. recipient_email = complaint.staff.email recipient_display = str(complaint.staff) else: - return Response( - {'error': 'Staff member has no email address'}, - status=status.HTTP_400_BAD_REQUEST - ) - + return Response({"error": "Staff member has no email address"}, status=status.HTTP_400_BAD_REQUEST) + # Send email with new link from django.contrib.sites.shortcuts import get_current_site from apps.notifications.services import NotificationService - + site = get_current_site(request) explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{new_token}/" - + # Build email subject subject = f"Explanation Request (Resent) - Complaint #{complaint.id}" - + # Build email body email_body = f""" Dear {recipient_display}, @@ -1480,7 +1050,7 @@ Status: {complaint.get_status_display()} {complaint.description} """ - + # Add patient info if available if complaint.patient: email_body += f""" @@ -1489,7 +1059,7 @@ PATIENT INFORMATION: Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn} """ - + email_body += f""" SUBMIT YOUR EXPLANATION: @@ -1504,7 +1074,7 @@ If you have any questions, please contact PX team. --- This is an automated message from PX360 Complaint Management System. """ - + # Send email try: notification_log = NotificationService.send_email( @@ -1513,165 +1083,302 @@ This is an automated message from PX360 Complaint Management System. message=email_body, related_object=complaint, metadata={ - 'notification_type': 'explanation_request_resent', - 'recipient_type': 'staff', - 'staff_id': str(complaint.staff.id), - 'explanation_id': str(explanation.id), - 'requested_by_id': str(request.user.id), - 'resent': True - } + "notification_type": "explanation_request_resent", + "recipient_type": "staff", + "staff_id": str(complaint.staff.id), + "explanation_id": str(explanation.id), + "requested_by_id": str(request.user.id), + "resent": True, + }, ) except Exception as e: - return Response( - {'error': f'Failed to send email: {str(e)}'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - + return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, - update_type='communication', + update_type="communication", message=f"Explanation request resent to {recipient_display}", created_by=request.user, metadata={ - 'explanation_id': str(explanation.id), - 'staff_id': str(complaint.staff.id), - 'notification_log_id': str(notification_log.id) if notification_log else None, - 'resent': True - } + "explanation_id": str(explanation.id), + "staff_id": str(complaint.staff.id), + "notification_log_id": str(notification_log.id) if notification_log else None, + "resent": True, + }, ) - + # Log audit AuditService.log_from_request( - event_type='explanation_resent', + event_type="explanation_resent", description=f"Explanation request resent to {recipient_display}", request=request, content_object=complaint, - metadata={ - 'explanation_id': str(explanation.id), - 'staff_id': str(complaint.staff.id) - } + metadata={"explanation_id": str(explanation.id), "staff_id": str(complaint.staff.id)}, ) - - return Response({ - 'success': True, - 'message': 'Explanation request resent successfully to staff member', - 'explanation_id': str(explanation.id), - 'recipient': recipient_display, - 'new_token': new_token, - 'explanation_link': explanation_link - }, status=status.HTTP_200_OK) - @action(detail=True, methods=['post']) + return Response( + { + "success": True, + "message": "Explanation request resent successfully to staff member", + "explanation_id": str(explanation.id), + "recipient": recipient_display, + "new_token": new_token, + "explanation_link": explanation_link, + }, + status=status.HTTP_200_OK, + ) + + @action(detail=True, methods=["post"]) + def send_explanation_reminder(self, request, pk=None): + """ + Manually send first or second reminder for explanation request. + + Allows admin to trigger a reminder email when the automated task didn't run. + """ + complaint = self.get_object() + + if not complaint.is_active_status: + return Response( + {"error": f"Cannot send reminder for complaint with status '{complaint.get_status_display()}'."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + explanation_id = request.data.get("explanation_id") + reminder_type = request.data.get("reminder_type", "first") + + if not explanation_id: + return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST) + + try: + explanation = ComplaintExplanation.objects.get(pk=explanation_id, complaint=complaint) + except ComplaintExplanation.DoesNotExist: + return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND) + + if explanation.is_used: + return Response({"error": "Explanation already submitted"}, status=status.HTTP_400_BAD_REQUEST) + + if reminder_type not in ("first", "second"): + return Response({"error": "reminder_type must be 'first' or 'second'"}, status=status.HTTP_400_BAD_REQUEST) + + if reminder_type == "first": + if explanation.reminder_sent_at: + return Response({"error": "First reminder already sent"}, status=status.HTTP_400_BAD_REQUEST) + else: + if not explanation.reminder_sent_at: + return Response( + {"error": "First reminder must be sent before second reminder"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if explanation.second_reminder_sent_at: + return Response({"error": "Second reminder already sent"}, status=status.HTTP_400_BAD_REQUEST) + + if not explanation.staff.email: + return Response({"error": "Staff member has no email address"}, status=status.HTTP_400_BAD_REQUEST) + + now = timezone.now() + hours_remaining = 0 + if explanation.sla_due_at: + hours_remaining = max(0, int((explanation.sla_due_at - now).total_seconds() / 3600)) + + context = { + "explanation": explanation, + "complaint": complaint, + "staff": explanation.staff, + "hours_remaining": hours_remaining, + "due_date": explanation.sla_due_at, + "site_url": request.build_absolute_uri("/").rstrip("/"), + } + + if reminder_type == "first": + subject = f"Reminder: Explanation Request - Complaint #{str(complaint.id)[:8]}" + try: + from django.template.loader import render_to_string + from django.core.mail import send_mail + from django.conf import settings + + message_en = render_to_string("complaints/emails/explanation_reminder_en.txt", context) + message_ar = render_to_string("complaints/emails/explanation_reminder_ar.txt", context) + + send_mail( + subject=subject, + message=f"{message_en}\n\n{message_ar}", + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[explanation.staff.email], + fail_silently=False, + ) + + explanation.reminder_sent_at = now + explanation.save(update_fields=["reminder_sent_at"]) + except Exception as e: + return Response( + {"error": f"Failed to send reminder: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + else: + subject = f"URGENT - Final Reminder: Explanation Request - Complaint #{str(complaint.id)[:8]}" + try: + from django.template.loader import render_to_string + from django.core.mail import send_mail + from django.conf import settings + + message_en = render_to_string("complaints/emails/explanation_second_reminder_en.txt", context) + message_ar = render_to_string("complaints/emails/explanation_second_reminder_ar.txt", context) + + send_mail( + subject=subject, + message=f"{message_en}\n\n{message_ar}", + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[explanation.staff.email], + fail_silently=False, + ) + + explanation.second_reminder_sent_at = now + explanation.save(update_fields=["second_reminder_sent_at"]) + except Exception as e: + return Response( + {"error": f"Failed to send reminder: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + label = "first" if reminder_type == "first" else "second" + recipient_display = str(explanation.staff) + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="communication", + message=f"{label.capitalize()} explanation reminder sent to {recipient_display}", + created_by=request.user, + metadata={ + "explanation_id": str(explanation.id), + "staff_id": str(explanation.staff.id), + "reminder_type": reminder_type, + }, + ) + + AuditService.log_from_request( + event_type=f"explanation_{label}_reminder_sent", + description=f"{label.capitalize()} explanation reminder sent to {recipient_display}", + request=request, + content_object=complaint, + metadata={"explanation_id": str(explanation.id), "staff_id": str(explanation.staff.id)}, + ) + + return Response( + { + "success": True, + "message": f"{label.capitalize()} reminder sent to {recipient_display}", + "explanation_id": str(explanation.id), + "reminder_type": reminder_type, + }, + status=status.HTTP_200_OK, + ) + + @action(detail=True, methods=["post"]) def review_explanation(self, request, pk=None): """ Review and mark an explanation as acceptable or not acceptable. - + Allows PX Admins to review submitted explanations and mark them. """ complaint = self.get_object() - + # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( - {'error': 'Only PX Admins or Hospital Admins can review explanations'}, - status=status.HTTP_403_FORBIDDEN + {"error": "Only PX Admins or Hospital Admins can review explanations"}, status=status.HTTP_403_FORBIDDEN ) - - explanation_id = request.data.get('explanation_id') - acceptance_status = request.data.get('acceptance_status') - acceptance_notes = request.data.get('acceptance_notes', '') - + + explanation_id = request.data.get("explanation_id") + acceptance_status = request.data.get("acceptance_status") + acceptance_notes = request.data.get("acceptance_notes", "") + if not explanation_id: - return Response( - {'error': 'explanation_id is required'}, - status=status.HTTP_400_BAD_REQUEST - ) - + return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST) + if not acceptance_status: return Response( - {'error': 'acceptance_status is required (acceptable or not_acceptable)'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "acceptance_status is required (acceptable or not_acceptable)"}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Validate acceptance status from .models import ComplaintExplanation - valid_statuses = [ComplaintExplanation.AcceptanceStatus.ACCEPTABLE, - ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE] + + valid_statuses = [ + ComplaintExplanation.AcceptanceStatus.ACCEPTABLE, + ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE, + ] if acceptance_status not in valid_statuses: return Response( - {'error': f'Invalid acceptance_status. Must be one of: {valid_statuses}'}, - status=status.HTTP_400_BAD_REQUEST + {"error": f"Invalid acceptance_status. Must be one of: {valid_statuses}"}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Get the explanation try: - explanation = ComplaintExplanation.objects.get( - id=explanation_id, - complaint=complaint - ) + explanation = ComplaintExplanation.objects.get(id=explanation_id, complaint=complaint) except ComplaintExplanation.DoesNotExist: - return Response( - {'error': 'Explanation not found'}, - status=status.HTTP_404_NOT_FOUND - ) - + return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND) + # Check if explanation has been submitted if not explanation.is_used: return Response( - {'error': 'Cannot review explanation that has not been submitted yet'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Cannot review explanation that has not been submitted yet"}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Update explanation explanation.acceptance_status = acceptance_status explanation.accepted_by = request.user explanation.accepted_at = timezone.now() explanation.acceptance_notes = acceptance_notes explanation.save() - + # Create complaint update - status_display = "Acceptable" if acceptance_status == ComplaintExplanation.AcceptanceStatus.ACCEPTABLE else "Not Acceptable" + status_display = ( + "Acceptable" if acceptance_status == ComplaintExplanation.AcceptanceStatus.ACCEPTABLE else "Not Acceptable" + ) ComplaintUpdate.objects.create( complaint=complaint, - update_type='note', + update_type="note", message=f"Explanation from {explanation.staff} marked as {status_display}", created_by=request.user, metadata={ - 'explanation_id': str(explanation.id), - 'staff_id': str(explanation.staff.id) if explanation.staff else None, - 'acceptance_status': acceptance_status, - 'acceptance_notes': acceptance_notes - } + "explanation_id": str(explanation.id), + "staff_id": str(explanation.staff.id) if explanation.staff else None, + "acceptance_status": acceptance_status, + "acceptance_notes": acceptance_notes, + }, ) - + # Log audit AuditService.log_from_request( - event_type='explanation_reviewed', + event_type="explanation_reviewed", description=f"Explanation marked as {status_display}", request=request, content_object=explanation, metadata={ - 'explanation_id': str(explanation.id), - 'acceptance_status': acceptance_status, - 'acceptance_notes': acceptance_notes + "explanation_id": str(explanation.id), + "acceptance_status": acceptance_status, + "acceptance_notes": acceptance_notes, + }, + ) + + return Response( + { + "success": True, + "message": f"Explanation marked as {status_display}", + "explanation_id": str(explanation.id), + "acceptance_status": acceptance_status, + "accepted_at": explanation.accepted_at, + "accepted_by": request.user.get_full_name(), } ) - - return Response({ - 'success': True, - 'message': f'Explanation marked as {status_display}', - 'explanation_id': str(explanation.id), - 'acceptance_status': acceptance_status, - 'accepted_at': explanation.accepted_at, - 'accepted_by': request.user.get_full_name() - }) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def escalate_explanation(self, request, pk=None): """ Escalate an explanation to the staff's manager. - + Marks the explanation as not acceptable and sends an explanation request to the staff's manager (report_to). """ @@ -1679,72 +1386,73 @@ This is an automated message from PX360 Complaint Management System. # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( - {'error': 'Only PX Admins or Hospital Admins can escalate explanations'}, - status=status.HTTP_403_FORBIDDEN + {"error": "Only PX Admins or Hospital Admins can escalate explanations"}, + status=status.HTTP_403_FORBIDDEN, ) - - explanation_id = request.data.get('explanation_id') - acceptance_notes = request.data.get('acceptance_notes', '') - + + explanation_id = request.data.get("explanation_id") + acceptance_notes = request.data.get("acceptance_notes", "") + if not explanation_id: - return Response( - {'error': 'explanation_id is required'}, - status=status.HTTP_400_BAD_REQUEST - ) - + return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST) + # Get the explanation try: - explanation = ComplaintExplanation.objects.select_related( - 'staff', 'staff__report_to' - ).get( - id=explanation_id, - complaint=complaint + explanation = ComplaintExplanation.objects.select_related("staff", "staff__report_to").get( + id=explanation_id, complaint=complaint ) except ComplaintExplanation.DoesNotExist: - return Response( - {'error': 'Explanation not found'}, - status=status.HTTP_404_NOT_FOUND - ) - + return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND) + # Check if explanation has been submitted if not explanation.is_used: return Response( - {'error': 'Cannot escalate explanation that has not been submitted yet'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Cannot escalate explanation that has not been submitted yet"}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Check if already escalated if explanation.escalated_to_manager: + return Response({"error": "Explanation has already been escalated"}, status=status.HTTP_400_BAD_REQUEST) + + # Use fallback chain to find escalation target + from apps.complaints.services.complaint_service import ComplaintService + from apps.organizations.models import Staff + + target_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=explanation.staff) + + if not target_user: return Response( - {'error': 'Explanation has already been escalated'}, - status=status.HTTP_400_BAD_REQUEST + {"error": f"No escalation target found (tried: {fallback_path})"}, + status=status.HTTP_400_BAD_REQUEST, ) - - # Check if staff has a manager - if not explanation.staff or not explanation.staff.report_to: + + manager = Staff.objects.filter(user=target_user).first() + + if not manager: return Response( - {'error': 'Staff member does not have a manager (report_to) assigned'}, - status=status.HTTP_400_BAD_REQUEST + {"error": f"Escalation target {target_user.get_full_name()} has no Staff record"}, + status=status.HTTP_400_BAD_REQUEST, ) - - manager = explanation.staff.report_to - + # Check if manager already has an explanation request for this complaint - existing_manager_explanation = ComplaintExplanation.objects.filter( - complaint=complaint, - staff=manager - ).first() - + existing_manager_explanation = ComplaintExplanation.objects.filter(complaint=complaint, staff=manager).first() + if existing_manager_explanation: return Response( - {'error': f'Manager {manager.get_full_name()} already has an explanation request for this complaint'}, - status=status.HTTP_400_BAD_REQUEST + {"error": f"Manager {manager.get_full_name()} already has an explanation request for this complaint"}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Generate token for manager explanation import secrets + manager_token = secrets.token_urlsafe(32) - + + request_message = f"Escalated from staff explanation. Staff: {explanation.staff.get_full_name() if explanation.staff else 'Unknown'}. Notes: {acceptance_notes}" + if fallback_path != "staff.report_to": + request_message += f" [Escalated via fallback: {fallback_path}]" + # Create manager explanation record manager_explanation = ComplaintExplanation.objects.create( complaint=complaint, @@ -1752,11 +1460,14 @@ This is an automated message from PX360 Complaint Management System. token=manager_token, is_used=False, requested_by=request.user, - request_message=f"Escalated from staff explanation. Staff: {explanation.staff.get_full_name() if explanation.staff else 'Unknown'}. Notes: {acceptance_notes}", - submitted_via='email_link', - email_sent_at=timezone.now() + request_message=request_message, + submitted_via="email_link", + email_sent_at=timezone.now(), + metadata={ + "escalation_fallback_path": fallback_path, + }, ) - + # Update original explanation explanation.acceptance_status = ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE explanation.accepted_by = request.user @@ -1765,28 +1476,28 @@ This is an automated message from PX360 Complaint Management System. explanation.escalated_to_manager = manager_explanation explanation.escalated_at = timezone.now() explanation.save() - + # Send email to manager from django.contrib.sites.shortcuts import get_current_site from apps.notifications.services import NotificationService - + site = get_current_site(request) explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{manager_token}/" - + manager_email = manager.email or (manager.user.email if manager.user else None) - + if manager_email: subject = f"Escalated Explanation Request - Complaint #{complaint.reference_number}" - + email_body = f"""Dear {manager.get_full_name()}, An explanation submitted by a staff member who reports to you has been marked as not acceptable and escalated to you for further review. STAFF MEMBER: ------------ -Name: {explanation.staff.get_full_name() if explanation.staff else 'Unknown'} -Employee ID: {explanation.staff.employee_id if explanation.staff else 'N/A'} -Department: {explanation.staff.department.name if explanation.staff and explanation.staff.department else 'N/A'} +Name: {explanation.staff.get_full_name() if explanation.staff else "Unknown"} +Employee ID: {explanation.staff.employee_id if explanation.staff else "N/A"} +Department: {explanation.staff.department.name if explanation.staff and explanation.staff.department else "N/A"} COMPLAINT DETAILS: ---------------- @@ -1801,7 +1512,7 @@ ORIGINAL EXPLANATION (Not Acceptable): ESCALATION NOTES: ----------------- -{acceptance_notes if acceptance_notes else 'No additional notes provided.'} +{acceptance_notes if acceptance_notes else "No additional notes provided."} PLEASE SUBMIT YOUR EXPLANATION: ------------------------------ @@ -1813,7 +1524,7 @@ Note: This link can only be used once. After submission, it will expire. --- This is an automated message from PX360 Complaint Management System. """ - + try: NotificationService.send_email( email=manager_email, @@ -1821,12 +1532,12 @@ This is an automated message from PX360 Complaint Management System. message=email_body, related_object=complaint, metadata={ - 'notification_type': 'escalated_explanation_request', - 'manager_id': str(manager.id), - 'staff_id': str(explanation.staff.id) if explanation.staff else None, - 'complaint_id': str(complaint.id), - 'original_explanation_id': str(explanation.id), - } + "notification_type": "escalated_explanation_request", + "manager_id": str(manager.id), + "staff_id": str(explanation.staff.id) if explanation.staff else None, + "complaint_id": str(complaint.id), + "original_explanation_id": str(explanation.id), + }, ) email_sent = True except Exception as e: @@ -1834,131 +1545,133 @@ This is an automated message from PX360 Complaint Management System. email_sent = False else: email_sent = False - + # Create complaint update ComplaintUpdate.objects.create( complaint=complaint, - update_type='note', + update_type="note", message=f"Explanation from {explanation.staff} marked as Not Acceptable and escalated to manager {manager.get_full_name()}", created_by=request.user, metadata={ - 'explanation_id': str(explanation.id), - 'staff_id': str(explanation.staff.id) if explanation.staff else None, - 'manager_id': str(manager.id), - 'manager_explanation_id': str(manager_explanation.id), - 'acceptance_status': 'not_acceptable', - 'acceptance_notes': acceptance_notes, - 'email_sent': email_sent - } + "explanation_id": str(explanation.id), + "staff_id": str(explanation.staff.id) if explanation.staff else None, + "manager_id": str(manager.id), + "manager_explanation_id": str(manager_explanation.id), + "acceptance_status": "not_acceptable", + "acceptance_notes": acceptance_notes, + "email_sent": email_sent, + }, ) - + # Log audit AuditService.log_from_request( - event_type='explanation_escalated', + event_type="explanation_escalated", description=f"Explanation escalated to manager {manager.get_full_name()}", request=request, content_object=explanation, metadata={ - 'explanation_id': str(explanation.id), - 'manager_id': str(manager.id), - 'manager_explanation_id': str(manager_explanation.id), - 'email_sent': email_sent + "explanation_id": str(explanation.id), + "manager_id": str(manager.id), + "manager_explanation_id": str(manager_explanation.id), + "email_sent": email_sent, + }, + ) + + return Response( + { + "success": True, + "message": f"Explanation escalated to manager {manager.get_full_name()}", + "explanation_id": str(explanation.id), + "manager_explanation_id": str(manager_explanation.id), + "manager_name": manager.get_full_name(), + "manager_email": manager_email, + "email_sent": email_sent, } ) - - return Response({ - 'success': True, - 'message': f'Explanation escalated to manager {manager.get_full_name()}', - 'explanation_id': str(explanation.id), - 'manager_explanation_id': str(manager_explanation.id), - 'manager_name': manager.get_full_name(), - 'manager_email': manager_email, - 'email_sent': email_sent - }) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def generate_ai_resolution(self, request, pk=None): """ Generate AI-powered resolution note based on complaint details and explanations. - + Analyzes the complaint description, staff explanations, and manager explanations to generate a comprehensive resolution note for admin review. """ complaint = self.get_object() - + # Check permission - same logic as can_manage_complaint user = request.user can_generate = ( - user.is_px_admin() or - (user.is_hospital_admin() and user.hospital == complaint.hospital) or - (user.is_department_manager() and user.department == complaint.department) or - complaint.assigned_to == user + user.is_px_admin() + or (user.is_hospital_admin() and user.hospital == complaint.hospital) + or (user.is_department_manager() and user.department == complaint.department) + or complaint.assigned_to == user ) - + if not can_generate: return Response( - {'error': 'You do not have permission to generate AI resolution for this complaint'}, - status=status.HTTP_403_FORBIDDEN + {"error": "You do not have permission to generate AI resolution for this complaint"}, + status=status.HTTP_403_FORBIDDEN, ) - + # Get all used explanations - explanations = complaint.explanations.filter(is_used=True).select_related('staff') - + explanations = complaint.explanations.filter(is_used=True).select_related("staff") + if not explanations.exists(): - return Response({ - 'success': False, - 'error': 'No explanations available to analyze. Please request explanations first.' - }, status=status.HTTP_400_BAD_REQUEST) - + return Response( + {"success": False, "error": "No explanations available to analyze. Please request explanations first."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Build context for AI context = { - 'complaint': { - 'title': complaint.title, - 'description': complaint.description, - 'severity': complaint.get_severity_display(), - 'priority': complaint.get_priority_display(), - 'patient_name': complaint.patient.get_full_name() if complaint.patient else 'Unknown', - 'department': complaint.department.name if complaint.department else 'Unknown', + "complaint": { + "title": complaint.title, + "description": complaint.description, + "severity": complaint.get_severity_display(), + "priority": complaint.get_priority_display(), + "patient_name": complaint.patient.get_full_name() if complaint.patient else "Unknown", + "department": complaint.department.name if complaint.department else "Unknown", }, - 'explanations': [] + "explanations": [], } - + for exp in explanations: exp_data = { - 'staff_name': exp.staff.get_full_name() if exp.staff else 'Unknown', - 'employee_id': exp.staff.employee_id if exp.staff else 'N/A', - 'department': exp.staff.department.name if exp.staff and exp.staff.department else 'N/A', - 'explanation': exp.explanation, - 'acceptance_status': exp.get_acceptance_status_display(), - 'submitted_at': exp.responded_at.strftime('%Y-%m-%d %H:%M') if exp.responded_at else 'Unknown' + "staff_name": exp.staff.get_full_name() if exp.staff else "Unknown", + "employee_id": exp.staff.employee_id if exp.staff else "N/A", + "department": exp.staff.department.name if exp.staff and exp.staff.department else "N/A", + "explanation": exp.explanation, + "acceptance_status": exp.get_acceptance_status_display(), + "submitted_at": exp.responded_at.strftime("%Y-%m-%d %H:%M") if exp.responded_at else "Unknown", } - context['explanations'].append(exp_data) - + context["explanations"].append(exp_data) + # Call AI service to generate resolution try: from apps.core.ai_service import AIService - + # Build prompt explanations_text = "" - for i, exp in enumerate(context['explanations'], 1): + for i, exp in enumerate(context["explanations"], 1): explanations_text += f""" Explanation {i}: -- Staff: {exp['staff_name']} (ID: {exp['employee_id']}, Dept: {exp['department']}) -- Status: {exp['acceptance_status']} -- Submitted: {exp['submitted_at']} -- Content: {exp['explanation']} +- Staff: {exp["staff_name"]} (ID: {exp["employee_id"]}, Dept: {exp["department"]}) +- Status: {exp["acceptance_status"]} +- Submitted: {exp["submitted_at"]} +- Content: {exp["explanation"]} """ - + prompt = f"""As a healthcare complaint resolution expert, analyze the following complaint and staff explanations to generate a comprehensive resolution note in BOTH English and Arabic. COMPLAINT DETAILS: -- Title: {context['complaint']['title']} -- Description: {context['complaint']['description']} -- Severity: {context['complaint']['severity']} -- Priority: {context['complaint']['priority']} -- Patient: {context['complaint']['patient_name']} -- Department: {context['complaint']['department']} +- Title: {context["complaint"]["title"]} +- Description: {context["complaint"]["description"]} +- Severity: {context["complaint"]["severity"]} +- Priority: {context["complaint"]["priority"]} +- Patient: {context["complaint"]["patient_name"]} +- Department: {context["complaint"]["department"]} STAFF EXPLANATIONS: {explanations_text} @@ -1992,71 +1705,72 @@ Always provide valid JSON output with both resolution_en and resolution_ar field system_prompt=system_prompt, temperature=0.4, max_tokens=1500, - response_format='json_object' + response_format="json_object", ) - + # Parse the JSON response import json + resolution_data = json.loads(ai_response) - resolution_en = resolution_data.get('resolution_en', '').strip() - resolution_ar = resolution_data.get('resolution_ar', '').strip() - + resolution_en = resolution_data.get("resolution_en", "").strip() + resolution_ar = resolution_data.get("resolution_ar", "").strip() + # Log the AI generation AuditService.log_from_request( - event_type='ai_resolution_generated', + event_type="ai_resolution_generated", description=f"AI resolution generated for complaint {complaint.reference_number}", request=request, content_object=complaint, metadata={ - 'complaint_id': str(complaint.id), - 'explanation_count': explanations.count(), - 'generated_resolution_en_length': len(resolution_en), - 'generated_resolution_ar_length': len(resolution_ar) + "complaint_id": str(complaint.id), + "explanation_count": explanations.count(), + "generated_resolution_en_length": len(resolution_en), + "generated_resolution_ar_length": len(resolution_ar), + }, + ) + + return Response( + { + "success": True, + "resolution_en": resolution_en, + "resolution_ar": resolution_ar, + "explanation_count": explanations.count(), } ) - - return Response({ - 'success': True, - 'resolution_en': resolution_en, - 'resolution_ar': resolution_ar, - 'explanation_count': explanations.count() - }) - + except Exception as e: logger.error(f"AI resolution generation failed: {e}") - return Response({ - 'success': False, - 'error': f'Failed to generate resolution: {str(e)}' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + {"success": False, "error": f"Failed to generate resolution: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def generate_resolution_suggestion(self, request, pk=None): """ Generate AI resolution suggestion based on complaint and acceptable explanation. - + Uses the staff explanation if acceptable, otherwise uses manager explanation. Returns a suggested resolution text that can be edited or used directly. """ complaint = self.get_object() - + # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( - {'error': 'Only PX Admins or Hospital Admins can generate resolution suggestions'}, - status=status.HTTP_403_FORBIDDEN + {"error": "Only PX Admins or Hospital Admins can generate resolution suggestions"}, + status=status.HTTP_403_FORBIDDEN, ) - + # Find acceptable explanation acceptable_explanation = None explanation_source = None - + # First, try to find an acceptable staff explanation staff_explanation = complaint.explanations.filter( - staff=complaint.staff, - is_used=True, - acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE + staff=complaint.staff, is_used=True, acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE ).first() - + if staff_explanation: acceptable_explanation = staff_explanation explanation_source = "staff" @@ -2065,46 +1779,51 @@ Always provide valid JSON output with both resolution_en and resolution_ar field manager_explanation = complaint.explanations.filter( is_used=True, acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE, - metadata__is_escalation=True + metadata__is_escalation=True, ).first() - + if manager_explanation: acceptable_explanation = manager_explanation explanation_source = "manager" - + if not acceptable_explanation: - return Response({ - 'error': 'No acceptable explanation found. Please review and mark an explanation as acceptable first.', - 'suggestion': None - }, status=status.HTTP_400_BAD_REQUEST) - + return Response( + { + "error": "No acceptable explanation found. Please review and mark an explanation as acceptable first.", + "suggestion": None, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Generate resolution using AI try: resolution_text = self._generate_ai_resolution( - complaint=complaint, - explanation=acceptable_explanation, - source=explanation_source + complaint=complaint, explanation=acceptable_explanation, source=explanation_source ) - - return Response({ - 'success': True, - 'suggestion': resolution_text, - 'source': explanation_source, - 'source_staff': acceptable_explanation.staff.get_full_name() if acceptable_explanation.staff else None, - 'explanation_id': str(acceptable_explanation.id) - }) - + + return Response( + { + "success": True, + "suggestion": resolution_text, + "source": explanation_source, + "source_staff": acceptable_explanation.staff.get_full_name() + if acceptable_explanation.staff + else None, + "explanation_id": str(acceptable_explanation.id), + } + ) + except Exception as e: logger.error(f"Failed to generate resolution: {e}") - return Response({ - 'error': 'Failed to generate resolution suggestion', - 'detail': str(e) - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + return Response( + {"error": "Failed to generate resolution suggestion", "detail": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + def _generate_ai_resolution(self, complaint, explanation, source): """ Generate AI resolution text based on complaint and explanation. - + This is a stub implementation. Replace with actual AI service call. """ # Build context for AI @@ -2114,13 +1833,13 @@ Complaint Description: {complaint.description} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} """ - + explanation_text = explanation.explanation explanation_by = explanation.staff.get_full_name() if explanation.staff else "Unknown" - + # For now, generate a template-based resolution # This should be replaced with actual AI service call - + resolution = f"""RESOLUTION SUMMARY Based on the complaint filed regarding: {complaint.title} @@ -2139,75 +1858,73 @@ ACTIONS TAKEN: - Steps have been taken to prevent recurrence The complaint is considered resolved.""" - + return resolution - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def save_resolution(self, request, pk=None): """ Save final resolution for the complaint. - + Allows user to save an edited or directly generated resolution. Optionally updates complaint status to RESOLVED. """ complaint = self.get_object() - + # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( - {'error': 'Only PX Admins or Hospital Admins can save resolutions'}, - status=status.HTTP_403_FORBIDDEN + {"error": "Only PX Admins or Hospital Admins can save resolutions"}, status=status.HTTP_403_FORBIDDEN ) - - resolution_text = request.data.get('resolution') - mark_resolved = request.data.get('mark_resolved', False) - + + resolution_text = request.data.get("resolution") + mark_resolved = request.data.get("mark_resolved", False) + if not resolution_text: - return Response( - {'error': 'Resolution text is required'}, - status=status.HTTP_400_BAD_REQUEST - ) - + return Response({"error": "Resolution text is required"}, status=status.HTTP_400_BAD_REQUEST) + # Save resolution complaint.resolution = resolution_text complaint.resolution_category = ComplaintResolutionCategory.FULL_ACTION_TAKEN - + if mark_resolved: complaint.status = ComplaintStatus.RESOLVED complaint.resolved_at = timezone.now() complaint.resolved_by = request.user - + complaint.save() - + # Create update ComplaintUpdate.objects.create( complaint=complaint, - update_type='resolution', + update_type="resolution", message=f"Resolution added{' and complaint marked as resolved' if mark_resolved else ''}", created_by=request.user, metadata={ - 'resolution_category': ComplaintResolutionCategory.FULL_ACTION_TAKEN, - 'mark_resolved': mark_resolved - } + "resolution_category": ComplaintResolutionCategory.FULL_ACTION_TAKEN, + "mark_resolved": mark_resolved, + }, ) - + # Log audit AuditService.log_from_request( - event_type='resolution_saved', + event_type="resolution_saved", description=f"Resolution saved{' and complaint resolved' if mark_resolved else ''}", request=request, content_object=complaint, - metadata={'mark_resolved': mark_resolved} + metadata={"mark_resolved": mark_resolved}, ) - - return Response({ - 'success': True, - 'message': f"Resolution saved successfully{' and complaint marked as resolved' if mark_resolved else ''}", - 'complaint_id': str(complaint.id), - 'status': complaint.status - }) - @action(detail=True, methods=['post']) + return Response( + { + "success": True, + "message": f"Resolution saved successfully{' and complaint marked as resolved' if mark_resolved else ''}", + "complaint_id": str(complaint.id), + "status": complaint.status, + } + ) + + @action(detail=True, methods=["post"]) def convert_to_appreciation(self, request, pk=None): """ Convert complaint to appreciation. @@ -2221,74 +1938,69 @@ The complaint is considered resolved.""" # Check if complaint is in active status if not complaint.is_active_status: return Response( - {'error': f"Cannot convert complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."}, - status=status.HTTP_400_BAD_REQUEST + { + "error": f"Cannot convert complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved." + }, + status=status.HTTP_400_BAD_REQUEST, ) # Check if complaint is appreciation type - if complaint.complaint_type != 'appreciation': + if complaint.complaint_type != "appreciation": return Response( - {'error': 'Only appreciation-type complaints can be converted to appreciations'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Only appreciation-type complaints can be converted to appreciations"}, + status=status.HTTP_400_BAD_REQUEST, ) # Check if already converted - if complaint.metadata.get('appreciation_id'): + if complaint.metadata.get("appreciation_id"): return Response( - {'error': 'This complaint has already been converted to an appreciation'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "This complaint has already been converted to an appreciation"}, + status=status.HTTP_400_BAD_REQUEST, ) # Get form data - recipient_type = request.data.get('recipient_type', 'user') # 'user' or 'physician' - recipient_id = request.data.get('recipient_id') - category_id = request.data.get('category_id') - message_en = request.data.get('message_en', complaint.description) - message_ar = request.data.get('message_ar', complaint.short_description_ar or '') - visibility = request.data.get('visibility', 'private') - is_anonymous = request.data.get('is_anonymous', True) - close_complaint = request.data.get('close_complaint', False) + recipient_type = request.data.get("recipient_type", "user") # 'user' or 'physician' + recipient_id = request.data.get("recipient_id") + category_id = request.data.get("category_id") + message_en = request.data.get("message_en", complaint.description) + message_ar = request.data.get("message_ar", complaint.short_description_ar or "") + visibility = request.data.get("visibility", "private") + is_anonymous = request.data.get("is_anonymous", True) + close_complaint = request.data.get("close_complaint", False) # Validate recipient from django.contrib.contenttypes.models import ContentType - if recipient_type == 'user': + if recipient_type == "user": from apps.accounts.models import User + try: recipient_user = User.objects.get(id=recipient_id) recipient_content_type = ContentType.objects.get_for_model(User) recipient_object_id = recipient_user.id except User.DoesNotExist: - return Response( - {'error': 'Recipient user not found'}, - status=status.HTTP_404_NOT_FOUND - ) - elif recipient_type == 'physician': + return Response({"error": "Recipient user not found"}, status=status.HTTP_404_NOT_FOUND) + elif recipient_type == "physician": from apps.physicians.models import Physician + try: recipient_physician = Physician.objects.get(id=recipient_id) recipient_content_type = ContentType.objects.get_for_model(Physician) recipient_object_id = recipient_physician.id except Physician.DoesNotExist: - return Response( - {'error': 'Recipient physician not found'}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Recipient physician not found"}, status=status.HTTP_404_NOT_FOUND) else: return Response( - {'error': 'Invalid recipient_type. Must be "user" or "physician"'}, - status=status.HTTP_400_BAD_REQUEST + {"error": 'Invalid recipient_type. Must be "user" or "physician"'}, status=status.HTTP_400_BAD_REQUEST ) # Validate category from apps.appreciation.models import AppreciationCategory + try: category = AppreciationCategory.objects.get(id=category_id) except AppreciationCategory.DoesNotExist: - return Response( - {'error': 'Appreciation category not found'}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Appreciation category not found"}, status=status.HTTP_404_NOT_FOUND) # Determine sender (patient or anonymous) sender = None @@ -2311,12 +2023,12 @@ The complaint is considered resolved.""" status=Appreciation.AppreciationStatus.DRAFT, is_anonymous=is_anonymous, metadata={ - 'source_complaint_id': str(complaint.id), - 'source_complaint_title': complaint.title, - 'converted_from_complaint': True, - 'converted_by': str(request.user.id), - 'converted_at': timezone.now().isoformat() - } + "source_complaint_id": str(complaint.id), + "source_complaint_title": complaint.title, + "converted_from_complaint": True, + "converted_by": str(request.user.id), + "converted_at": timezone.now().isoformat(), + }, ) # Send appreciation (triggers notification) @@ -2325,100 +2037,104 @@ The complaint is considered resolved.""" # Link appreciation to complaint if not complaint.metadata: complaint.metadata = {} - complaint.metadata['appreciation_id'] = str(appreciation.id) - complaint.metadata['converted_to_appreciation'] = True - complaint.metadata['converted_to_appreciation_at'] = timezone.now().isoformat() - complaint.metadata['converted_by'] = str(request.user.id) - complaint.save(update_fields=['metadata']) + complaint.metadata["appreciation_id"] = str(appreciation.id) + complaint.metadata["converted_to_appreciation"] = True + complaint.metadata["converted_to_appreciation_at"] = timezone.now().isoformat() + complaint.metadata["converted_by"] = str(request.user.id) + complaint.save(update_fields=["metadata"]) # Close complaint if requested complaint_closed = False if close_complaint: - complaint.status = 'closed' + complaint.status = "closed" complaint.closed_at = timezone.now() complaint.closed_by = request.user - complaint.save(update_fields=['status', 'closed_at', 'closed_by']) + complaint.save(update_fields=["status", "closed_at", "closed_by"]) complaint_closed = True # Create status update ComplaintUpdate.objects.create( complaint=complaint, - update_type='status_change', + update_type="status_change", message="Complaint closed after converting to appreciation", created_by=request.user, - old_status='open', - new_status='closed' + old_status="open", + new_status="closed", ) # Create conversion update ComplaintUpdate.objects.create( complaint=complaint, - update_type='note', + update_type="note", message=f"Converted to appreciation (Appreciation #{appreciation.id})", created_by=request.user, metadata={ - 'appreciation_id': str(appreciation.id), - 'converted_from_complaint': True, - 'close_complaint': close_complaint - } + "appreciation_id": str(appreciation.id), + "converted_from_complaint": True, + "close_complaint": close_complaint, + }, ) # Log audit AuditService.log_from_request( - event_type='complaint_converted_to_appreciation', + event_type="complaint_converted_to_appreciation", description=f"Complaint converted to appreciation: {appreciation.message_en[:100]}", request=request, content_object=complaint, metadata={ - 'appreciation_id': str(appreciation.id), - 'close_complaint': close_complaint, - 'is_anonymous': is_anonymous - } + "appreciation_id": str(appreciation.id), + "close_complaint": close_complaint, + "is_anonymous": is_anonymous, + }, ) # Build appreciation URL from django.contrib.sites.shortcuts import get_current_site + site = get_current_site(request) appreciation_url = f"https://{site.domain}/appreciations/{appreciation.id}/" - return Response({ - 'success': True, - 'message': 'Complaint successfully converted to appreciation', - 'appreciation_id': str(appreciation.id), - 'appreciation_url': appreciation_url, - 'complaint_closed': complaint_closed - }, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['post']) + return Response( + { + "success": True, + "message": "Complaint successfully converted to appreciation", + "appreciation_id": str(appreciation.id), + "appreciation_url": appreciation_url, + "complaint_closed": complaint_closed, + }, + status=status.HTTP_201_CREATED, + ) + + @action(detail=True, methods=["post"]) def send_resolution_notification(self, request, pk=None): """ Send resolution notification to patient. - + Sends email notification to patient with resolution details. Optionally sends SMS if phone number is available. Creates ComplaintUpdate entry and logs audit trail. """ complaint = self.get_object() - + # Check if complaint is resolved - if complaint.status != 'resolved': + if complaint.status != "resolved": return Response( - {'error': 'Can only send resolution notification for resolved complaints'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Can only send resolution notification for resolved complaints"}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Check if resolution exists if not complaint.resolution: return Response( - {'error': 'Complaint must have resolution details before sending notification'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Complaint must have resolution details before sending notification"}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Determine recipient (patient or contact) recipient_email = None recipient_phone = None recipient_name = None - + # Try patient first if complaint.patient: if complaint.patient.email: @@ -2426,7 +2142,7 @@ The complaint is considered resolved.""" if complaint.patient.phone: recipient_phone = complaint.patient.phone recipient_name = complaint.patient.get_full_name() - + # Fall back to contact info if not recipient_email: recipient_email = complaint.contact_email @@ -2434,17 +2150,16 @@ The complaint is considered resolved.""" recipient_name = complaint.contact_name if not recipient_phone: recipient_phone = complaint.contact_phone - + # Validate at least email is available if not recipient_email: return Response( - {'error': 'No email address found for patient or contact'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "No email address found for patient or contact"}, status=status.HTTP_400_BAD_REQUEST ) - + # Build email subject and body subject = f"Complaint Resolution - #{complaint.id}" - + # Build email body email_body = f""" Dear {recipient_name}, @@ -2464,14 +2179,14 @@ Category: {complaint.get_resolution_category_display()} {complaint.resolution} """ - + # Add additional context if available if complaint.resolved_by: email_body += f""" Resolved by: {complaint.resolved_by.get_full_name()} -Resolved at: {complaint.resolved_at.strftime('%Y-%m-%d %H:%M')} +Resolved at: {complaint.resolved_at.strftime("%Y-%m-%d %H:%M")} """ - + email_body += f""" If you have any further questions or concerns about this resolution, @@ -2482,10 +2197,10 @@ Thank you for your patience and for giving us the opportunity to address your co --- This is an automated message from PX360 Complaint Management System. """ - + # Send email using NotificationService from apps.notifications.services import NotificationService - + try: notification_log = NotificationService.send_email( email=recipient_email, @@ -2493,411 +2208,410 @@ This is an automated message from PX360 Complaint Management System. message=email_body, related_object=complaint, metadata={ - 'notification_type': 'resolution_notification', - 'recipient_name': recipient_name, - 'recipient_phone': recipient_phone, - 'sender_id': str(request.user.id), - 'resolution_category': complaint.resolution_category - } + "notification_type": "resolution_notification", + "recipient_name": recipient_name, + "recipient_phone": recipient_phone, + "sender_id": str(request.user.id), + "resolution_category": complaint.resolution_category, + }, ) except Exception as e: - return Response( - {'error': f'Failed to send email: {str(e)}'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - + return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + # Optionally send SMS if phone is available sms_sent = False if recipient_phone: try: # Build SMS message (shorter) sms_message = f"PX360: Your complaint #{complaint.id} has been resolved. Resolution Category: {complaint.get_resolution_category_display()}. Check your email for details." - + # Send SMS (if SMS service is configured) # This is a placeholder - actual SMS sending depends on your SMS provider sms_sent = True # Set to True if SMS is actually sent - + if sms_sent: # Log SMS in metadata - complaint.metadata['resolution_sms_sent_at'] = timezone.now().isoformat() - complaint.metadata['resolution_sms_sent_to'] = recipient_phone - complaint.save(update_fields=['metadata']) + complaint.metadata["resolution_sms_sent_at"] = timezone.now().isoformat() + complaint.metadata["resolution_sms_sent_to"] = recipient_phone + complaint.save(update_fields=["metadata"]) except Exception as e: # Log error but don't fail the operation import logging + logger = logging.getLogger(__name__) logger.error(f"Failed to send SMS: {e}") - + # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, - update_type='communication', + update_type="communication", message=f"Resolution notification sent to {recipient_name}", created_by=request.user, metadata={ - 'notification_type': 'resolution_notification', - 'recipient_name': recipient_name, - 'recipient_email': recipient_email, - 'notification_log_id': str(notification_log.id) if notification_log else None, - 'sms_sent': sms_sent - } + "notification_type": "resolution_notification", + "recipient_name": recipient_name, + "recipient_email": recipient_email, + "notification_log_id": str(notification_log.id) if notification_log else None, + "sms_sent": sms_sent, + }, ) - + # Log audit AuditService.log_from_request( - event_type='resolution_notification_sent', + event_type="resolution_notification_sent", description=f"Resolution notification sent to {recipient_name}", request=request, content_object=complaint, metadata={ - 'recipient_name': recipient_name, - 'recipient_email': recipient_email, - 'recipient_phone': recipient_phone, - 'sms_sent': sms_sent, - 'resolution_category': complaint.resolution_category + "recipient_name": recipient_name, + "recipient_email": recipient_email, + "recipient_phone": recipient_phone, + "sms_sent": sms_sent, + "resolution_category": complaint.resolution_category, + }, + ) + + return Response( + { + "success": True, + "message": "Resolution notification sent successfully", + "recipient": recipient_name, + "recipient_email": recipient_email, + "sms_sent": sms_sent, } ) - - return Response({ - 'success': True, - 'message': 'Resolution notification sent successfully', - 'recipient': recipient_name, - 'recipient_email': recipient_email, - 'sms_sent': sms_sent - }) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def update_taxonomy(self, request, pk=None): """ Update the 4-level SHCT taxonomy classification for a complaint. - + Allows PX Admins and Hospital Admins to manually correct or update the AI-generated taxonomy classification. - + Required fields: - domain_id: UUID of the Level 1 Domain (ComplaintCategory) - category_id: UUID of the Level 2 Category (ComplaintCategory) - subcategory_id: UUID of the Level 3 Subcategory (ComplaintCategory) - classification_id: UUID of the Level 4 Classification (ComplaintCategory) - + Optional fields: - note: Optional note explaining the change """ complaint = self.get_object() user = request.user - + # Check permissions if not (user.is_px_admin() or user.is_hospital_admin()): return Response( - {'error': 'Only PX Admins and Hospital Admins can update taxonomy'}, - status=status.HTTP_403_FORBIDDEN + {"error": "Only PX Admins and Hospital Admins can update taxonomy"}, status=status.HTTP_403_FORBIDDEN ) - + # Get taxonomy IDs from request - domain_id = request.data.get('domain_id') - category_id = request.data.get('category_id') - subcategory_id = request.data.get('subcategory_id') - classification_id = request.data.get('classification_id') - note = request.data.get('note', '') - + domain_id = request.data.get("domain_id") + category_id = request.data.get("category_id") + subcategory_id = request.data.get("subcategory_id") + classification_id = request.data.get("classification_id") + note = request.data.get("note", "") + # Validate that at least one field is provided if not any([domain_id, category_id, subcategory_id, classification_id]): return Response( - {'error': 'At least one taxonomy level (domain_id, category_id, subcategory_id, or classification_id) must be provided'}, - status=status.HTTP_400_BAD_REQUEST + { + "error": "At least one taxonomy level (domain_id, category_id, subcategory_id, or classification_id) must be provided" + }, + status=status.HTTP_400_BAD_REQUEST, ) - + from apps.complaints.models import ComplaintCategory - + changes = [] errors = [] - + # Store old values for logging old_domain = complaint.domain old_category = complaint.category old_subcategory_obj = complaint.subcategory_obj old_classification_obj = complaint.classification_obj - + try: # Level 1: Domain if domain_id: try: domain = ComplaintCategory.objects.get( - id=domain_id, - level=ComplaintCategory.LevelChoices.DOMAIN, - is_active=True + id=domain_id, level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True ) complaint.domain = domain changes.append(f"Domain: {old_domain.name_en if old_domain else 'None'} -> {domain.name_en}") except ComplaintCategory.DoesNotExist: errors.append(f"Domain with ID {domain_id} not found or not active") - + # Level 2: Category (must be child of domain if domain is set) if category_id: try: category_query = ComplaintCategory.objects.filter( - id=category_id, - level=ComplaintCategory.LevelChoices.CATEGORY, - is_active=True + id=category_id, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True ) # If domain is set, ensure category is child of domain if complaint.domain: category_query = category_query.filter(parent=complaint.domain) - + category = category_query.first() if category: complaint.category = category - changes.append(f"Category: {old_category.name_en if old_category else 'None'} -> {category.name_en}") + changes.append( + f"Category: {old_category.name_en if old_category else 'None'} -> {category.name_en}" + ) else: - errors.append(f"Category with ID {category_id} not found, not active, or not under the selected domain") + errors.append( + f"Category with ID {category_id} not found, not active, or not under the selected domain" + ) except Exception as e: errors.append(f"Error setting category: {str(e)}") - + # Level 3: Subcategory (must be child of category if category is set) if subcategory_id: try: subcategory_query = ComplaintCategory.objects.filter( - id=subcategory_id, - level=ComplaintCategory.LevelChoices.SUBCATEGORY, - is_active=True + id=subcategory_id, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True ) # If category is set, ensure subcategory is child of category if complaint.category: subcategory_query = subcategory_query.filter(parent=complaint.category) - + subcategory = subcategory_query.first() if subcategory: complaint.subcategory_obj = subcategory complaint.subcategory = subcategory.code or subcategory.name_en - changes.append(f"Subcategory: {old_subcategory_obj.name_en if old_subcategory_obj else 'None'} -> {subcategory.name_en}") + changes.append( + f"Subcategory: {old_subcategory_obj.name_en if old_subcategory_obj else 'None'} -> {subcategory.name_en}" + ) else: - errors.append(f"Subcategory with ID {subcategory_id} not found, not active, or not under the selected category") + errors.append( + f"Subcategory with ID {subcategory_id} not found, not active, or not under the selected category" + ) except Exception as e: errors.append(f"Error setting subcategory: {str(e)}") - + # Level 4: Classification (must be child of subcategory if subcategory is set) if classification_id: try: classification_query = ComplaintCategory.objects.filter( - id=classification_id, - level=ComplaintCategory.LevelChoices.CLASSIFICATION, - is_active=True + id=classification_id, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True ) # If subcategory_obj is set, ensure classification is child of subcategory if complaint.subcategory_obj: classification_query = classification_query.filter(parent=complaint.subcategory_obj) - + classification = classification_query.first() if classification: complaint.classification_obj = classification complaint.classification = classification.code or classification.name_en - changes.append(f"Classification: {old_classification_obj.name_en if old_classification_obj else 'None'} -> {classification.name_en}") + changes.append( + f"Classification: {old_classification_obj.name_en if old_classification_obj else 'None'} -> {classification.name_en}" + ) else: - errors.append(f"Classification with ID {classification_id} not found, not active, or not under the selected subcategory") + errors.append( + f"Classification with ID {classification_id} not found, not active, or not under the selected subcategory" + ) except Exception as e: errors.append(f"Error setting classification: {str(e)}") - + # If there were errors, return them without saving if errors: return Response( - { - 'error': 'Some taxonomy levels could not be updated', - 'errors': errors, - 'changes_made': changes - }, - status=status.HTTP_400_BAD_REQUEST + {"error": "Some taxonomy levels could not be updated", "errors": errors, "changes_made": changes}, + status=status.HTTP_400_BAD_REQUEST, ) - + # Save the complaint - complaint.save(update_fields=['domain', 'category', 'subcategory', 'subcategory_obj', 'classification', 'classification_obj']) - + complaint.save( + update_fields=[ + "domain", + "category", + "subcategory", + "subcategory_obj", + "classification", + "classification_obj", + ] + ) + # Create timeline entry change_message = "Taxonomy updated:\n" + "\n".join(changes) if note: change_message += f"\n\nNote: {note}" - + ComplaintUpdate.objects.create( complaint=complaint, - update_type='note', + update_type="note", message=change_message, created_by=user, - metadata={ - 'taxonomy_update': True, - 'changes': changes, - 'note': note, - 'updated_by': str(user.id) - } + metadata={"taxonomy_update": True, "changes": changes, "note": note, "updated_by": str(user.id)}, ) - + # Log audit AuditService.log_from_request( - event_type='taxonomy_updated', + event_type="taxonomy_updated", description=f"Taxonomy updated for complaint: {complaint.title}", request=request, content_object=complaint, - metadata={ - 'changes': changes, - 'note': note, - 'updated_by': str(user.id) - } + metadata={"changes": changes, "note": note, "updated_by": str(user.id)}, ) - + # Update metadata to reflect manual update if not complaint.metadata: complaint.metadata = {} - if 'ai_analysis' not in complaint.metadata: - complaint.metadata['ai_analysis'] = {} - complaint.metadata['ai_analysis']['taxonomy_manually_updated'] = True - complaint.metadata['ai_analysis']['taxonomy_updated_by'] = str(user.id) - complaint.metadata['ai_analysis']['taxonomy_updated_at'] = timezone.now().isoformat() - complaint.save(update_fields=['metadata']) - - return Response({ - 'success': True, - 'message': 'Taxonomy updated successfully', - 'changes': changes, - 'taxonomy': { - 'domain': { - 'id': str(complaint.domain.id) if complaint.domain else None, - 'name_en': complaint.domain.name_en if complaint.domain else None, - 'name_ar': complaint.domain.name_ar if complaint.domain else None + if "ai_analysis" not in complaint.metadata: + complaint.metadata["ai_analysis"] = {} + complaint.metadata["ai_analysis"]["taxonomy_manually_updated"] = True + complaint.metadata["ai_analysis"]["taxonomy_updated_by"] = str(user.id) + complaint.metadata["ai_analysis"]["taxonomy_updated_at"] = timezone.now().isoformat() + complaint.save(update_fields=["metadata"]) + + return Response( + { + "success": True, + "message": "Taxonomy updated successfully", + "changes": changes, + "taxonomy": { + "domain": { + "id": str(complaint.domain.id) if complaint.domain else None, + "name_en": complaint.domain.name_en if complaint.domain else None, + "name_ar": complaint.domain.name_ar if complaint.domain else None, + }, + "category": { + "id": str(complaint.category.id) if complaint.category else None, + "name_en": complaint.category.name_en if complaint.category else None, + "name_ar": complaint.category.name_ar if complaint.category else None, + }, + "subcategory": { + "id": str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None, + "name_en": complaint.subcategory_obj.name_en if complaint.subcategory_obj else None, + "name_ar": complaint.subcategory_obj.name_ar if complaint.subcategory_obj else None, + "code": complaint.subcategory, + }, + "classification": { + "id": str(complaint.classification_obj.id) if complaint.classification_obj else None, + "name_en": complaint.classification_obj.name_en if complaint.classification_obj else None, + "name_ar": complaint.classification_obj.name_ar if complaint.classification_obj else None, + "code": complaint.classification, + }, }, - 'category': { - 'id': str(complaint.category.id) if complaint.category else None, - 'name_en': complaint.category.name_en if complaint.category else None, - 'name_ar': complaint.category.name_ar if complaint.category else None - }, - 'subcategory': { - 'id': str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None, - 'name_en': complaint.subcategory_obj.name_en if complaint.subcategory_obj else None, - 'name_ar': complaint.subcategory_obj.name_ar if complaint.subcategory_obj else None, - 'code': complaint.subcategory - }, - 'classification': { - 'id': str(complaint.classification_obj.id) if complaint.classification_obj else None, - 'name_en': complaint.classification_obj.name_en if complaint.classification_obj else None, - 'name_ar': complaint.classification_obj.name_ar if complaint.classification_obj else None, - 'code': complaint.classification - } } - }) - + ) + except Exception as e: logger.error(f"Error updating taxonomy: {str(e)}") return Response( - {'error': f'Failed to update taxonomy: {str(e)}'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + {"error": f"Failed to update taxonomy: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def taxonomy_options(self, request, pk=None): """ Get available taxonomy options for the complaint's hierarchy. - + Returns the full SHCT taxonomy hierarchy for building cascading dropdowns. Includes only active categories. """ complaint = self.get_object() - + from apps.complaints.models import ComplaintCategory from django.db.models import Prefetch - + # Build the hierarchy domains = ComplaintCategory.objects.filter( - level=ComplaintCategory.LevelChoices.DOMAIN, - is_active=True - ).order_by('order', 'name_en') - + level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True + ).order_by("order", "name_en") + result = [] for domain in domains: domain_data = { - 'id': str(domain.id), - 'code': domain.code or domain.name_en.upper(), - 'name_en': domain.name_en, - 'name_ar': domain.name_ar, - 'is_selected': complaint.domain and complaint.domain.id == domain.id, - 'categories': [] + "id": str(domain.id), + "code": domain.code or domain.name_en.upper(), + "name_en": domain.name_en, + "name_ar": domain.name_ar, + "is_selected": complaint.domain and complaint.domain.id == domain.id, + "categories": [], } - + # Get categories for this domain categories = ComplaintCategory.objects.filter( - parent=domain, - level=ComplaintCategory.LevelChoices.CATEGORY, - is_active=True - ).order_by('order', 'name_en') - + parent=domain, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True + ).order_by("order", "name_en") + for category in categories: category_data = { - 'id': str(category.id), - 'code': category.code or category.name_en.upper(), - 'name_en': category.name_en, - 'name_ar': category.name_ar, - 'is_selected': complaint.category and complaint.category.id == category.id, - 'subcategories': [] + "id": str(category.id), + "code": category.code or category.name_en.upper(), + "name_en": category.name_en, + "name_ar": category.name_ar, + "is_selected": complaint.category and complaint.category.id == category.id, + "subcategories": [], } - + # Get subcategories for this category subcategories = ComplaintCategory.objects.filter( - parent=category, - level=ComplaintCategory.LevelChoices.SUBCATEGORY, - is_active=True - ).order_by('order', 'name_en') - + parent=category, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True + ).order_by("order", "name_en") + for subcategory in subcategories: subcategory_data = { - 'id': str(subcategory.id), - 'code': subcategory.code or subcategory.name_en.upper(), - 'name_en': subcategory.name_en, - 'name_ar': subcategory.name_ar, - 'is_selected': complaint.subcategory_obj and complaint.subcategory_obj.id == subcategory.id, - 'classifications': [] + "id": str(subcategory.id), + "code": subcategory.code or subcategory.name_en.upper(), + "name_en": subcategory.name_en, + "name_ar": subcategory.name_ar, + "is_selected": complaint.subcategory_obj and complaint.subcategory_obj.id == subcategory.id, + "classifications": [], } - + # Get classifications for this subcategory classifications = ComplaintCategory.objects.filter( - parent=subcategory, - level=ComplaintCategory.LevelChoices.CLASSIFICATION, - is_active=True - ).order_by('order', 'name_en') - + parent=subcategory, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True + ).order_by("order", "name_en") + for classification in classifications: classification_data = { - 'id': str(classification.id), - 'code': classification.code, - 'name_en': classification.name_en, - 'name_ar': classification.name_ar, - 'is_selected': complaint.classification_obj and complaint.classification_obj.id == classification.id + "id": str(classification.id), + "code": classification.code, + "name_en": classification.name_en, + "name_ar": classification.name_ar, + "is_selected": complaint.classification_obj + and complaint.classification_obj.id == classification.id, } - subcategory_data['classifications'].append(classification_data) - - category_data['subcategories'].append(subcategory_data) - - domain_data['categories'].append(category_data) - + subcategory_data["classifications"].append(classification_data) + + category_data["subcategories"].append(subcategory_data) + + domain_data["categories"].append(category_data) + result.append(domain_data) - - return Response({ - 'success': True, - 'hierarchy': result, - 'current': { - 'domain_id': str(complaint.domain.id) if complaint.domain else None, - 'category_id': str(complaint.category.id) if complaint.category else None, - 'subcategory_id': str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None, - 'classification_id': str(complaint.classification_obj.id) if complaint.classification_obj else None + + return Response( + { + "success": True, + "hierarchy": result, + "current": { + "domain_id": str(complaint.domain.id) if complaint.domain else None, + "category_id": str(complaint.category.id) if complaint.category else None, + "subcategory_id": str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None, + "classification_id": str(complaint.classification_obj.id) if complaint.classification_obj else None, + }, } - }) + ) class ComplaintAttachmentViewSet(viewsets.ModelViewSet): """ViewSet for Complaint Attachments""" + queryset = ComplaintAttachment.objects.all() serializer_class = ComplaintAttachmentSerializer permission_classes = [IsAuthenticated] - filterset_fields = ['complaint'] - ordering = ['-created_at'] + filterset_fields = ["complaint"] + ordering = ["-created_at"] def get_queryset(self): - queryset = super().get_queryset().select_related('complaint', 'uploaded_by') + queryset = super().get_queryset().select_related("complaint", "uploaded_by") user = self.request.user # Filter based on complaint access @@ -2915,32 +2629,41 @@ class ComplaintAttachmentViewSet(viewsets.ModelViewSet): class InquiryViewSet(viewsets.ModelViewSet): """ViewSet for Inquiries""" + queryset = Inquiry.objects.all() serializer_class = InquirySerializer permission_classes = [IsAuthenticated] - filterset_fields = ['status', 'category', 'source', 'hospital', 'department', 'assigned_to', 'hospital__organization'] - search_fields = ['subject', 'message', 'contact_name', 'patient__mrn'] - ordering_fields = ['created_at'] - ordering = ['-created_at'] + filterset_fields = [ + "status", + "category", + "source", + "hospital", + "department", + "assigned_to", + "hospital__organization", + ] + search_fields = ["subject", "message", "contact_name", "patient__mrn"] + ordering_fields = ["created_at"] + ordering = ["-created_at"] def perform_create(self, serializer): """Auto-set created_by from request.user""" inquiry = serializer.save(created_by=self.request.user) AuditService.log_from_request( - event_type='inquiry_created', + event_type="inquiry_created", description=f"Inquiry created: {inquiry.subject}", request=self.request, content_object=inquiry, - metadata={ - 'created_by': str(inquiry.created_by.id) if inquiry.created_by else None - } + metadata={"created_by": str(inquiry.created_by.id) if inquiry.created_by else None}, ) def get_queryset(self): """Filter inquiries based on user role""" - queryset = super().get_queryset().select_related( - 'patient', 'hospital', 'department', 'assigned_to', 'responded_by', 'created_by' + queryset = ( + super() + .get_queryset() + .select_related("patient", "hospital", "department", "assigned_to", "responded_by", "created_by") ) user = self.request.user @@ -2950,11 +2673,11 @@ class InquiryViewSet(viewsets.ModelViewSet): return queryset # Source Users see ONLY inquiries THEY created - if hasattr(user, 'source_user_profile') and user.source_user_profile.exists(): + if hasattr(user, "source_user_profile") and user.source_user_profile.exists(): return queryset.filter(created_by=user) # Patients see ONLY their own inquiries (if they have user accounts) - if hasattr(user, 'patient_profile'): + if hasattr(user, "patient_profile"): return queryset.filter(patient__user=user) # Hospital Admins see inquiries for their hospital @@ -2971,39 +2694,35 @@ class InquiryViewSet(viewsets.ModelViewSet): return queryset.none() - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def respond(self, request, pk=None): """Respond to inquiry""" inquiry = self.get_object() - response_text = request.data.get('response') + response_text = request.data.get("response") if not response_text: - return Response( - {'error': 'response is required'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "response is required"}, status=status.HTTP_400_BAD_REQUEST) inquiry.response = response_text inquiry.responded_at = timezone.now() inquiry.responded_by = request.user - inquiry.status = 'resolved' + inquiry.status = "resolved" inquiry.save() - return Response({'message': 'Response submitted successfully'}) + return Response({"message": "Response submitted successfully"}) class ComplaintPRInteractionViewSet(viewsets.ModelViewSet): """ViewSet for PR Interactions""" + queryset = ComplaintPRInteraction.objects.all() serializer_class = ComplaintPRInteractionSerializer permission_classes = [IsAuthenticated] - filterset_fields = ['complaint', 'contact_method', 'procedure_explained', 'pr_staff'] - ordering = ['-contact_date'] + filterset_fields = ["complaint", "contact_method", "procedure_explained", "pr_staff"] + ordering = ["-contact_date"] def get_queryset(self): - queryset = super().get_queryset().select_related( - 'complaint', 'pr_staff', 'created_by' - ) + queryset = super().get_queryset().select_related("complaint", "pr_staff", "created_by") user = self.request.user # Filter based on complaint access @@ -3025,38 +2744,36 @@ class ComplaintPRInteractionViewSet(viewsets.ModelViewSet): # Create complaint update ComplaintUpdate.objects.create( complaint=interaction.complaint, - update_type='note', + update_type="note", message=f"PR Interaction recorded: Contact via {interaction.get_contact_method_display()}", created_by=self.request.user, metadata={ - 'interaction_id': str(interaction.id), - 'contact_method': interaction.contact_method, - 'procedure_explained': interaction.procedure_explained - } + "interaction_id": str(interaction.id), + "contact_method": interaction.contact_method, + "procedure_explained": interaction.procedure_explained, + }, ) AuditService.log_from_request( - event_type='pr_interaction_created', + event_type="pr_interaction_created", description=f"PR Interaction recorded for complaint: {interaction.complaint.title}", request=self.request, content_object=interaction, - metadata={ - 'complaint_id': str(interaction.complaint.id), - 'contact_method': interaction.contact_method - } + metadata={"complaint_id": str(interaction.complaint.id), "contact_method": interaction.contact_method}, ) class ComplaintMeetingViewSet(viewsets.ModelViewSet): """ViewSet for Complaint Meetings""" + queryset = ComplaintMeeting.objects.all() serializer_class = ComplaintMeetingSerializer permission_classes = [IsAuthenticated] - filterset_fields = ['complaint', 'meeting_type'] - ordering = ['-meeting_date'] + filterset_fields = ["complaint", "meeting_type"] + ordering = ["-meeting_date"] def get_queryset(self): - queryset = super().get_queryset().select_related('complaint', 'created_by') + queryset = super().get_queryset().select_related("complaint", "created_by") user = self.request.user # Filter based on complaint access @@ -3078,42 +2795,36 @@ class ComplaintMeetingViewSet(viewsets.ModelViewSet): # Create complaint update ComplaintUpdate.objects.create( complaint=meeting.complaint, - update_type='note', + update_type="note", message=f"Meeting recorded: {meeting.get_meeting_type_display()} - {meeting.outcome[:100] if meeting.outcome else ''}", created_by=self.request.user, - metadata={ - 'meeting_id': str(meeting.id), - 'meeting_type': meeting.meeting_type - } + metadata={"meeting_id": str(meeting.id), "meeting_type": meeting.meeting_type}, ) # If outcome is provided, consider it as resolution - if meeting.outcome and meeting.complaint.status not in ['resolved', 'closed']: - meeting.complaint.status = 'resolved' + if meeting.outcome and meeting.complaint.status not in ["resolved", "closed"]: + meeting.complaint.status = "resolved" meeting.complaint.resolution = meeting.outcome meeting.complaint.resolved_at = timezone.now() meeting.complaint.resolved_by = self.request.user - meeting.complaint.save(update_fields=['status', 'resolution', 'resolved_at', 'resolved_by']) + meeting.complaint.save(update_fields=["status", "resolution", "resolved_at", "resolved_by"]) # Create status update ComplaintUpdate.objects.create( complaint=meeting.complaint, - update_type='status_change', + update_type="status_change", message=f"Complaint resolved through meeting", created_by=self.request.user, - old_status='in_progress', - new_status='resolved' + old_status="in_progress", + new_status="resolved", ) AuditService.log_from_request( - event_type='meeting_created', + event_type="meeting_created", description=f"Complaint Meeting recorded for: {meeting.complaint.title}", request=self.request, content_object=meeting, - metadata={ - 'complaint_id': str(meeting.complaint.id), - 'meeting_type': meeting.meeting_type - } + metadata={"complaint_id": str(meeting.complaint.id), "meeting_type": meeting.meeting_type}, ) @@ -3127,126 +2838,110 @@ from django.views.decorators.csrf import csrf_exempt def api_locations(request): """ API endpoint to get all locations for complaint form. - + Returns JSON list of all locations ordered by English name. Public endpoint (no authentication required). """ from apps.organizations.models import Location - - locations = Location.objects.all().order_by('name_en') - + + locations = Location.objects.all().order_by("name_en") + locations_list = [ { - 'id': loc.id, - 'name': str(loc) # Uses __str__ which prefers English name + "id": loc.id, + "name": str(loc), # Uses __str__ which prefers English name } for loc in locations ] - - return JsonResponse({ - 'success': True, - 'locations': locations_list, - 'count': len(locations_list) - }) + + return JsonResponse({"success": True, "locations": locations_list, "count": len(locations_list)}) @require_GET def api_sections(request, location_id): """ API endpoint to get sections for a specific location. - + Returns JSON list of main sections that have subsections for given location. Public endpoint (no authentication required). """ from apps.organizations.models import MainSection, SubSection - + # Get available sections that have subsections for this location - available_section_ids = SubSection.objects.filter( - location_id=location_id - ).values_list('main_section_id', flat=True).distinct() - - sections = MainSection.objects.filter( - id__in=available_section_ids - ).order_by('name_en') - + available_section_ids = ( + SubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct() + ) + + sections = MainSection.objects.filter(id__in=available_section_ids).order_by("name_en") + sections_list = [ { - 'id': section.id, - 'name': str(section) # Uses __str__ which prefers English name + "id": section.id, + "name": str(section), # Uses __str__ which prefers English name } for section in sections ] - - return JsonResponse({ - 'success': True, - 'location_id': location_id, - 'sections': sections_list, - 'count': len(sections_list) - }) + + return JsonResponse( + {"success": True, "location_id": location_id, "sections": sections_list, "count": len(sections_list)} + ) @require_GET def api_subsections(request, location_id, section_id): """ API endpoint to get subsections for a specific location and section. - + Returns JSON list of subsections for given location and section. Public endpoint (no authentication required). """ from apps.organizations.models import SubSection - - subsections = SubSection.objects.filter( - location_id=location_id, - main_section_id=section_id - ).order_by('name_en') - + + subsections = SubSection.objects.filter(location_id=location_id, main_section_id=section_id).order_by("name_en") + subsections_list = [ { - 'id': sub.internal_id, # SubSection uses internal_id as primary key - 'name': str(sub) # Uses __str__ which prefers English name + "id": sub.internal_id, # SubSection uses internal_id as primary key + "name": str(sub), # Uses __str__ which prefers English name } for sub in subsections ] - - return JsonResponse({ - 'success': True, - 'location_id': location_id, - 'section_id': section_id, - 'subsections': subsections_list, - 'count': len(subsections_list) - }) + + return JsonResponse( + { + "success": True, + "location_id": location_id, + "section_id": section_id, + "subsections": subsections_list, + "count": len(subsections_list), + } + ) @require_GET def api_departments(request, hospital_id): """ API endpoint to get departments for a specific hospital. - + Returns JSON list of departments for given hospital. Public endpoint (no authentication required). """ from apps.organizations.models import Department - - departments = Department.objects.filter( - hospital_id=hospital_id, - status='active' - ).order_by('name') - + + departments = Department.objects.filter(hospital_id=hospital_id, status="active").order_by("name") + departments_list = [ { - 'id': dept.id, - 'name': dept.name # Department model has 'name' field, not name_en + "id": dept.id, + "name": dept.name, # Department model has 'name' field, not name_en } for dept in departments ] - - return JsonResponse({ - 'success': True, - 'hospital_id': hospital_id, - 'departments': departments_list, - 'count': len(departments_list) - }) + + return JsonResponse( + {"success": True, "hospital_id": hospital_id, "departments": departments_list, "count": len(departments_list)} + ) def complaint_explanation_form(request, complaint_id, token): @@ -3266,40 +2961,45 @@ def complaint_explanation_form(request, complaint_id, token): # Validate token with staff and department prefetch # Also prefetch escalation relationship to show original staff explanation to manager explanation = get_object_or_404( - ComplaintExplanation.objects.select_related( - 'staff', 'staff__department', 'staff__report_to' - ).prefetch_related('escalated_from_staff'), + ComplaintExplanation.objects.select_related("staff", "staff__department", "staff__report_to").prefetch_related( + "escalated_from_staff" + ), complaint=complaint, - token=token + token=token, ) - + # Get original staff explanation if this is an escalation original_explanation = None - if hasattr(explanation, 'escalated_from_staff'): + if hasattr(explanation, "escalated_from_staff"): # This explanation was created as a result of escalation # Get the original staff explanation - original_explanation = ComplaintExplanation.objects.filter( - escalated_to_manager=explanation - ).select_related('staff').first() + original_explanation = ( + ComplaintExplanation.objects.filter(escalated_to_manager=explanation).select_related("staff").first() + ) # Check if token is already used if explanation.is_used: - return render(request, 'complaints/explanation_already_submitted.html', { - 'complaint': complaint, - 'explanation': explanation - }) + return render( + request, + "complaints/explanation_already_submitted.html", + {"complaint": complaint, "explanation": explanation}, + ) - if request.method == 'POST': + if request.method == "POST": # Handle form submission - explanation_text = request.POST.get('explanation', '').strip() + explanation_text = request.POST.get("explanation", "").strip() if not explanation_text: - return render(request, 'complaints/explanation_form.html', { - 'complaint': complaint, - 'explanation': explanation, - 'original_explanation': original_explanation, - 'error': 'Please provide your explanation.' - }) + return render( + request, + "complaints/explanation_form.html", + { + "complaint": complaint, + "explanation": explanation, + "original_explanation": original_explanation, + "error": "Please provide your explanation.", + }, + ) # Save explanation explanation.explanation = explanation_text @@ -3308,14 +3008,14 @@ def complaint_explanation_form(request, complaint_id, token): explanation.save() # Handle file attachments - files = request.FILES.getlist('attachments') + files = request.FILES.getlist("attachments") for uploaded_file in files: ExplanationAttachment.objects.create( explanation=explanation, file=uploaded_file, filename=uploaded_file.name, file_type=uploaded_file.content_type, - file_size=uploaded_file.size + file_size=uploaded_file.size, ) # Notify complaint assignee @@ -3368,41 +3068,42 @@ This is an automated message from PX360 Complaint Management System. message=email_body, related_object=complaint, metadata={ - 'notification_type': 'explanation_submitted', - 'explanation_id': str(explanation.id), - 'staff_id': str(explanation.staff.id) if explanation.staff else None - } + "notification_type": "explanation_submitted", + "explanation_id": str(explanation.id), + "staff_id": str(explanation.staff.id) if explanation.staff else None, + }, ) except Exception as e: # Log error but don't fail the submission import logging + logger = logging.getLogger(__name__) logger.error(f"Failed to send notification email: {e}") # Create complaint update ComplaintUpdate.objects.create( complaint=complaint, - update_type='communication', + update_type="communication", message=f"Explanation submitted by {explanation.staff}", metadata={ - 'explanation_id': str(explanation.id), - 'staff_id': str(explanation.staff.id) if explanation.staff else None - } + "explanation_id": str(explanation.id), + "staff_id": str(explanation.staff.id) if explanation.staff else None, + }, ) # Redirect to success page - return render(request, 'complaints/explanation_success.html', { - 'complaint': complaint, - 'explanation': explanation, - 'attachment_count': len(files) - }) + return render( + request, + "complaints/explanation_success.html", + {"complaint": complaint, "explanation": explanation, "attachment_count": len(files)}, + ) # GET request - display form - return render(request, 'complaints/explanation_form.html', { - 'complaint': complaint, - 'explanation': explanation, - 'original_explanation': original_explanation - }) + return render( + request, + "complaints/explanation_form.html", + {"complaint": complaint, "explanation": explanation, "original_explanation": original_explanation}, + ) from django.http import HttpResponse @@ -3421,7 +3122,7 @@ def generate_complaint_pdf(request, pk): # Check permissions user = request.user if not user.is_authenticated: - return HttpResponse('Unauthorized', status=401) + return HttpResponse("Unauthorized", status=401) # Check if user can view this complaint can_view = False @@ -3435,71 +3136,75 @@ def generate_complaint_pdf(request, pk): can_view = True if not can_view: - return HttpResponse('Forbidden', status=403) + return HttpResponse("Forbidden", status=403) # Render HTML template with comprehensive data from django.template.loader import render_to_string - + # Get explanations with their acceptance status - explanations = complaint.explanations.all().select_related('staff', 'accepted_by').prefetch_related('attachments') - + explanations = complaint.explanations.all().select_related("staff", "accepted_by").prefetch_related("attachments") + # Get timeline updates - timeline = complaint.updates.all().select_related('created_by')[:20] # Limit to last 20 - + timeline = complaint.updates.all().select_related("created_by")[:20] # Limit to last 20 + # Get related PX Actions from apps.px_action_center.models import PXAction from django.contrib.contenttypes.models import ContentType + complaint_ct = ContentType.objects.get_for_model(Complaint) - px_actions = PXAction.objects.filter( - content_type=complaint_ct, - object_id=complaint.id - ).order_by('-created_at')[:5] - - html_string = render_to_string('complaints/complaint_pdf.html', { - 'complaint': complaint, - 'explanations': explanations, - 'timeline': timeline, - 'px_actions': px_actions, - 'generated_at': timezone.now(), - }) + px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at")[:5] + + html_string = render_to_string( + "complaints/complaint_pdf.html", + { + "complaint": complaint, + "explanations": explanations, + "timeline": timeline, + "px_actions": px_actions, + "generated_at": timezone.now(), + }, + ) # Generate PDF using WeasyPrint try: from weasyprint import HTML + pdf_file = HTML(string=html_string).write_pdf() # Create response - response = HttpResponse(pdf_file, content_type='application/pdf') - + response = HttpResponse(pdf_file, content_type="application/pdf") + # Allow PDF to be displayed in iframe (same origin only) - response['X-Frame-Options'] = 'SAMEORIGIN' - + response["X-Frame-Options"] = "SAMEORIGIN" + # Check if view=inline is requested (for iframe display) - view_mode = request.GET.get('view', 'download') - if view_mode == 'inline': + view_mode = request.GET.get("view", "download") + if view_mode == "inline": # Display inline in browser - response['Content-Disposition'] = 'inline' + response["Content-Disposition"] = "inline" else: # Download as attachment from datetime import datetime - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf" - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response["Content-Disposition"] = f'attachment; filename="{filename}"' # Log audit AuditService.log_from_request( - event_type='pdf_generated', + event_type="pdf_generated", description=f"PDF generated for complaint: {complaint.title}", request=request, content_object=complaint, - metadata={'complaint_id': str(pk)} + metadata={"complaint_id": str(pk)}, ) return response except ImportError: - return HttpResponse('WeasyPrint is not installed. Please install it to generate PDFs.', status=500) + return HttpResponse("WeasyPrint is not installed. Please install it to generate PDFs.", status=500) except Exception as e: import logging + logger = logging.getLogger(__name__) logger.error(f"Error generating PDF for complaint {pk}: {e}") - return HttpResponse(f'Error generating PDF: {str(e)}', status=500) + return HttpResponse(f"Error generating PDF: {str(e)}", status=500) diff --git a/apps/core/ai_service.py b/apps/core/ai_service.py index b1bd58a..fe4e877 100644 --- a/apps/core/ai_service.py +++ b/apps/core/ai_service.py @@ -11,6 +11,7 @@ Features: - Entity extraction - Language detection """ + import os import json import logging @@ -24,6 +25,7 @@ logger = logging.getLogger(__name__) class AIServiceError(Exception): """Custom exception for AI service errors""" + pass @@ -38,7 +40,6 @@ class AIService: # OPENROUTER_API_KEY = "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" OPENROUTER_API_KEY = "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" - # Default configuration DEFAULT_MODEL = "openrouter/nvidia/nemotron-3-super-120b-a12b:free" # DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free" @@ -47,11 +48,10 @@ class AIService: DEFAULT_TIMEOUT = 30 # Severity choices - SEVERITY_CHOICES = ['low', 'medium', 'high', 'critical'] + SEVERITY_CHOICES = ["low", "medium", "high", "critical"] # Priority choices - PRIORITY_CHOICES = ['low', 'medium', 'high'] - + PRIORITY_CHOICES = ["low", "medium", "high"] @classmethod def _get_api_key(cls) -> str: @@ -65,34 +65,36 @@ class AIService: @classmethod def _get_model(cls) -> str: """Get AI model from settings""" - return getattr(settings, 'AI_MODEL') or cls.DEFAULT_MODEL + return getattr(settings, "AI_MODEL") or cls.DEFAULT_MODEL @classmethod def _get_temperature(cls) -> float: """Get AI temperature from settings""" - return float(getattr(settings, 'AI_TEMPERATURE')) or cls.DEFAULT_TEMPERATURE + return float(getattr(settings, "AI_TEMPERATURE")) or cls.DEFAULT_TEMPERATURE @classmethod def _get_max_tokens(cls) -> int: """Get max tokens from settings""" - return int(getattr(settings, 'AI_MAX_TOKENS')) or cls.DEFAULT_MAX_TOKENS + return int(getattr(settings, "AI_MAX_TOKENS")) or cls.DEFAULT_MAX_TOKENS + @classmethod def _get_complaint_categories(cls) -> List[str]: """Get complaint categories from settings""" from apps.complaints.models import ComplaintCategory - return ComplaintCategory.objects.all().values_list('name_en', flat=True) + return ComplaintCategory.objects.all().values_list("name_en", flat=True) @classmethod def _get_complaint_sub_categories(cls, category) -> List[str]: """Get complaint subcategories for a given category name""" from apps.complaints.models import ComplaintCategory + if category: try: # Find the category by name and get its subcategories category_obj = ComplaintCategory.objects.filter(name_en=category).first() if category_obj: - return ComplaintCategory.objects.filter(parent=category_obj).values_list('name_en', flat=True) + return ComplaintCategory.objects.filter(parent=category_obj).values_list("name_en", flat=True) except Exception as e: logger.error(f"Error fetching subcategories: {e}") return [] @@ -110,7 +112,7 @@ class AIService: for category in parent_categories: # Get subcategories for this parent subcategories = list( - ComplaintCategory.objects.filter(parent=category).values_list('name_en', flat=True) + ComplaintCategory.objects.filter(parent=category).values_list("name_en", flat=True) ) result[category.name_en] = subcategories if subcategories else [] @@ -123,7 +125,7 @@ class AIService: def _get_taxonomy_hierarchy(cls) -> Dict: """ Get complete 4-level SHCT taxonomy hierarchy for AI classification. - + Returns a structured dictionary representing the full taxonomy tree: { 'domains': [ @@ -158,73 +160,66 @@ class AIService: """ from apps.complaints.models import ComplaintCategory - result = {'domains': []} + result = {"domains": []} try: # Get Level 1: Domains domains = ComplaintCategory.objects.filter( - level=ComplaintCategory.LevelChoices.DOMAIN, - is_active=True - ).order_by('domain_type', 'order') + level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True + ).order_by("domain_type", "order") for domain in domains: domain_data = { - 'code': domain.code or domain.name_en.upper(), - 'name_en': domain.name_en, - 'name_ar': domain.name_ar or '', - 'categories': [] + "code": domain.code or domain.name_en.upper(), + "name_en": domain.name_en, + "name_ar": domain.name_ar or "", + "categories": [], } # Get Level 2: Categories for this domain categories = ComplaintCategory.objects.filter( - parent=domain, - level=ComplaintCategory.LevelChoices.CATEGORY, - is_active=True - ).order_by('order') + parent=domain, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True + ).order_by("order") for category in categories: category_data = { - 'code': category.code or category.name_en.upper(), - 'name_en': category.name_en, - 'name_ar': category.name_ar or '', - 'subcategories': [] + "code": category.code or category.name_en.upper(), + "name_en": category.name_en, + "name_ar": category.name_ar or "", + "subcategories": [], } # Get Level 3: Subcategories for this category subcategories = ComplaintCategory.objects.filter( - parent=category, - level=ComplaintCategory.LevelChoices.SUBCATEGORY, - is_active=True - ).order_by('order') + parent=category, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True + ).order_by("order") for subcategory in subcategories: subcategory_data = { - 'code': subcategory.code or subcategory.name_en.upper(), - 'name_en': subcategory.name_en, - 'name_ar': subcategory.name_ar or '', - 'classifications': [] + "code": subcategory.code or subcategory.name_en.upper(), + "name_en": subcategory.name_en, + "name_ar": subcategory.name_ar or "", + "classifications": [], } # Get Level 4: Classifications for this subcategory classifications = ComplaintCategory.objects.filter( - parent=subcategory, - level=ComplaintCategory.LevelChoices.CLASSIFICATION, - is_active=True - ).order_by('order') + parent=subcategory, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True + ).order_by("order") for classification in classifications: classification_data = { - 'code': classification.code, - 'name_en': classification.name_en, - 'name_ar': classification.name_ar or '' + "code": classification.code, + "name_en": classification.name_en, + "name_ar": classification.name_ar or "", } - subcategory_data['classifications'].append(classification_data) + subcategory_data["classifications"].append(classification_data) - category_data['subcategories'].append(subcategory_data) + category_data["subcategories"].append(subcategory_data) - domain_data['categories'].append(category_data) + domain_data["categories"].append(category_data) - result['domains'].append(domain_data) + result["domains"].append(domain_data) logger.info(f"Taxonomy hierarchy loaded: {len(result['domains'])} domains") @@ -234,16 +229,18 @@ class AIService: return result @classmethod - def _find_category_by_name_or_code(cls, name_or_code: str, level: int, parent_id: str = None, fuzzy_threshold: float = 0.85) -> dict: + def _find_category_by_name_or_code( + cls, name_or_code: str, level: int, parent_id: str = None, fuzzy_threshold: float = 0.85 + ) -> dict: """ Find a ComplaintCategory by name (English/Arabic) or code with fuzzy matching. - + Args: name_or_code: The name or code to search for level: The level of category to find (1-4) parent_id: Optional parent category ID for hierarchical search fuzzy_threshold: Minimum similarity ratio for fuzzy matching (0.0 to 1.0) - + Returns: Dictionary with category details or None if not found: { @@ -277,40 +274,40 @@ class AIService: # Exact match on code if cat.code and cat.code.lower() == search_term: return { - 'id': str(cat.id), - 'code': cat.code, - 'name_en': cat.name_en, - 'name_ar': cat.name_ar or '', - 'level': cat.level, - 'parent_id': str(cat.parent_id) if cat.parent else None, - 'confidence': 1.0, - 'match_type': 'exact_code' + "id": str(cat.id), + "code": cat.code, + "name_en": cat.name_en, + "name_ar": cat.name_ar or "", + "level": cat.level, + "parent_id": str(cat.parent_id) if cat.parent else None, + "confidence": 1.0, + "match_type": "exact_code", } # Exact match on English name if cat.name_en.lower() == search_term: return { - 'id': str(cat.id), - 'code': cat.code or '', - 'name_en': cat.name_en, - 'name_ar': cat.name_ar or '', - 'level': cat.level, - 'parent_id': str(cat.parent_id) if cat.parent else None, - 'confidence': 0.95, - 'match_type': 'exact_name_en' + "id": str(cat.id), + "code": cat.code or "", + "name_en": cat.name_en, + "name_ar": cat.name_ar or "", + "level": cat.level, + "parent_id": str(cat.parent_id) if cat.parent else None, + "confidence": 0.95, + "match_type": "exact_name_en", } # Exact match on Arabic name if cat.name_ar and cat.name_ar.lower() == search_term: return { - 'id': str(cat.id), - 'code': cat.code or '', - 'name_en': cat.name_en, - 'name_ar': cat.name_ar, - 'level': cat.level, - 'parent_id': str(cat.parent_id) if cat.parent else None, - 'confidence': 0.95, - 'match_type': 'exact_name_ar' + "id": str(cat.id), + "code": cat.code or "", + "name_en": cat.name_en, + "name_ar": cat.name_ar, + "level": cat.level, + "parent_id": str(cat.parent_id) if cat.parent else None, + "confidence": 0.95, + "match_type": "exact_name_ar", } # No exact match found, try fuzzy matching @@ -318,38 +315,44 @@ class AIService: # Try English name ratio_en = SequenceMatcher(None, search_term, cat.name_en.lower()).ratio() if ratio_en >= fuzzy_threshold: - matches.append({ - 'id': str(cat.id), - 'code': cat.code or '', - 'name_en': cat.name_en, - 'name_ar': cat.name_ar or '', - 'level': cat.level, - 'parent_id': str(cat.parent_id) if cat.parent else None, - 'confidence': ratio_en * 0.85, # Lower confidence for fuzzy matches - 'match_type': 'fuzzy_name_en' - }) + matches.append( + { + "id": str(cat.id), + "code": cat.code or "", + "name_en": cat.name_en, + "name_ar": cat.name_ar or "", + "level": cat.level, + "parent_id": str(cat.parent_id) if cat.parent else None, + "confidence": ratio_en * 0.85, # Lower confidence for fuzzy matches + "match_type": "fuzzy_name_en", + } + ) # Try Arabic name if cat.name_ar: ratio_ar = SequenceMatcher(None, search_term, cat.name_ar.lower()).ratio() if ratio_ar >= fuzzy_threshold: # Avoid duplicate matches - if not any(m['id'] == str(cat.id) for m in matches): - matches.append({ - 'id': str(cat.id), - 'code': cat.code or '', - 'name_en': cat.name_en, - 'name_ar': cat.name_ar, - 'level': cat.level, - 'parent_id': str(cat.parent_id) if cat.parent else None, - 'confidence': ratio_ar * 0.85, - 'match_type': 'fuzzy_name_ar' - }) + if not any(m["id"] == str(cat.id) for m in matches): + matches.append( + { + "id": str(cat.id), + "code": cat.code or "", + "name_en": cat.name_en, + "name_ar": cat.name_ar, + "level": cat.level, + "parent_id": str(cat.parent_id) if cat.parent else None, + "confidence": ratio_ar * 0.85, + "match_type": "fuzzy_name_ar", + } + ) # Sort by confidence and return best match if matches: - matches.sort(key=lambda x: x['confidence'], reverse=True) - logger.info(f"Fuzzy match found for '{name_or_code}': {matches[0]['name_en']} (confidence: {matches[0]['confidence']:.2f})") + matches.sort(key=lambda x: x["confidence"], reverse=True) + logger.info( + f"Fuzzy match found for '{name_or_code}': {matches[0]['name_en']} (confidence: {matches[0]['confidence']:.2f})" + ) return matches[0] logger.warning(f"No match found for taxonomy term: '{name_or_code}' (level: {level})") @@ -359,10 +362,10 @@ class AIService: def _map_ai_taxonomy_to_db(cls, taxonomy_data: Dict) -> Dict: """ Map AI taxonomy classification to database objects. - + Takes AI-provided taxonomy classification (codes/names for domain, category, subcategory, classification) and maps them to actual ComplaintCategory database objects with fuzzy matching fallback. - + Args: taxonomy_data: Dictionary from AI with taxonomy classifications: { @@ -371,7 +374,7 @@ class AIService: 'subcategory': {'code': 'EXAMINATION', 'name_en': 'Examination', ...}, 'classification': {'code': 'exam_not_performed', 'name_en': 'Examination not performed', ...} } - + Returns: Dictionary with mapped database IDs and confidence scores: { @@ -384,80 +387,80 @@ class AIService: """ from apps.complaints.models import ComplaintCategory - result = { - 'domain': None, - 'category': None, - 'subcategory': None, - 'classification': None, - 'errors': [] - } + result = {"domain": None, "category": None, "subcategory": None, "classification": None, "errors": []} # Level 1: Domain (no parent) - if 'domain' in taxonomy_data and taxonomy_data['domain']: - domain_data = taxonomy_data['domain'] - domain_code = domain_data.get('code') - domain_name = domain_data.get('name_en') + if "domain" in taxonomy_data and taxonomy_data["domain"]: + domain_data = taxonomy_data["domain"] + domain_code = domain_data.get("code") + domain_name = domain_data.get("name_en") # Try code first, then name search_term = domain_code or domain_name if search_term: - result['domain'] = cls._find_category_by_name_or_code( - name_or_code=search_term, - level=ComplaintCategory.LevelChoices.DOMAIN, - parent_id=None + result["domain"] = cls._find_category_by_name_or_code( + name_or_code=search_term, level=ComplaintCategory.LevelChoices.DOMAIN, parent_id=None ) - if not result['domain']: - result['errors'].append(f"Domain not found: {search_term}") + if not result["domain"]: + result["errors"].append(f"Domain not found: {search_term}") # Level 2: Category (child of domain) - if 'category' in taxonomy_data and taxonomy_data['category'] and result['domain']: - category_data = taxonomy_data['category'] - category_code = category_data.get('code') - category_name = category_data.get('name_en') + if "category" in taxonomy_data and taxonomy_data["category"] and result["domain"]: + category_data = taxonomy_data["category"] + category_code = category_data.get("code") + category_name = category_data.get("name_en") search_term = category_code or category_name if search_term: - result['category'] = cls._find_category_by_name_or_code( + result["category"] = cls._find_category_by_name_or_code( name_or_code=search_term, level=ComplaintCategory.LevelChoices.CATEGORY, - parent_id=result['domain']['id'] + parent_id=result["domain"]["id"], ) - if not result['category']: - result['errors'].append(f"Category not found: {search_term} (under domain: {result['domain']['name_en']})") + if not result["category"]: + result["errors"].append( + f"Category not found: {search_term} (under domain: {result['domain']['name_en']})" + ) # Level 3: Subcategory (child of category) - if 'subcategory' in taxonomy_data and taxonomy_data['subcategory'] and result['category']: - subcategory_data = taxonomy_data['subcategory'] - subcategory_code = subcategory_data.get('code') - subcategory_name = subcategory_data.get('name_en') + if "subcategory" in taxonomy_data and taxonomy_data["subcategory"] and result["category"]: + subcategory_data = taxonomy_data["subcategory"] + subcategory_code = subcategory_data.get("code") + subcategory_name = subcategory_data.get("name_en") search_term = subcategory_code or subcategory_name if search_term: - result['subcategory'] = cls._find_category_by_name_or_code( + result["subcategory"] = cls._find_category_by_name_or_code( name_or_code=search_term, level=ComplaintCategory.LevelChoices.SUBCATEGORY, - parent_id=result['category']['id'] + parent_id=result["category"]["id"], ) - if not result['subcategory']: - result['errors'].append(f"Subcategory not found: {search_term} (under category: {result['category']['name_en']})") + if not result["subcategory"]: + result["errors"].append( + f"Subcategory not found: {search_term} (under category: {result['category']['name_en']})" + ) # Level 4: Classification (child of subcategory) - if 'classification' in taxonomy_data and taxonomy_data['classification'] and result['subcategory']: - classification_data = taxonomy_data['classification'] - classification_code = classification_data.get('code') - classification_name = classification_data.get('name_en') + if "classification" in taxonomy_data and taxonomy_data["classification"] and result["subcategory"]: + classification_data = taxonomy_data["classification"] + classification_code = classification_data.get("code") + classification_name = classification_data.get("name_en") search_term = classification_code or classification_name if search_term: - result['classification'] = cls._find_category_by_name_or_code( + result["classification"] = cls._find_category_by_name_or_code( name_or_code=search_term, level=ComplaintCategory.LevelChoices.CLASSIFICATION, - parent_id=result['subcategory']['id'] + parent_id=result["subcategory"]["id"], ) - if not result['classification']: - result['errors'].append(f"Classification not found: {search_term} (under subcategory: {result['subcategory']['name_en']})") + if not result["classification"]: + result["errors"].append( + f"Classification not found: {search_term} (under subcategory: {result['subcategory']['name_en']})" + ) - logger.info(f"Taxonomy mapping complete: domain={result['domain']}, category={result['category']}, subcategory={result['subcategory']}, classification={result['classification']}, errors={len(result['errors'])}") + logger.info( + f"Taxonomy mapping complete: domain={result['domain']}, category={result['category']}, subcategory={result['subcategory']}, classification={result['classification']}, errors={len(result['errors'])}" + ) return result @@ -467,16 +470,14 @@ class AIService: from apps.organizations.models import Department try: - departments = Department.objects.filter( - hospital_id=hospital_id, - status='active' - ).values_list('name', flat=True) + departments = Department.objects.filter(hospital_id=hospital_id, status="active").values_list( + "name", flat=True + ) return list(departments) except Exception as e: logger.error(f"Error fetching hospital departments: {e}") return [] - @classmethod def chat_completion( cls, @@ -485,7 +486,7 @@ class AIService: temperature: Optional[float] = None, max_tokens: Optional[int] = None, system_prompt: Optional[str] = None, - response_format: Optional[str] = None + response_format: Optional[str] = None, ) -> str: """ Perform a chat completion using LiteLLM. @@ -520,10 +521,7 @@ class AIService: messages.append({"role": "user", "content": prompt}) # Build kwargs - kwargs = { - "model": "openrouter/nvidia/nemotron-3-super-120b-a12b:free", - "messages": messages - } + kwargs = {"model": "openrouter/nvidia/nemotron-3-super-120b-a12b:free", "messages": messages} if response_format: kwargs["response_format"] = {"type": response_format} @@ -548,10 +546,10 @@ class AIService: description: str = "", category: Optional[str] = None, hospital_id: Optional[int] = None, - use_taxonomy: bool = True + use_taxonomy: bool = True, ) -> Dict[str, Any]: """ - Analyze a complaint and determine type (complaint vs appreciation), title, severity, priority, + Analyze a complaint and determine type (complaint vs appreciation), title, severity, priority, 4-level SHCT taxonomy (Domain, Category, Subcategory, Classification), and department. Args: @@ -652,7 +650,8 @@ class AIService: Instructions: 1. If no title is provided, generate a concise title (max 10 words) that summarizes the complaint in BOTH English and Arabic - 2. Generate a short_description (2-3 sentences) that captures the main issue and context in BOTH English and Arabic + 2. Generate a brief_summary (exactly 2-3 words) that serves as a quick tag/label for the complaint in BOTH English and Arabic. Examples: "Wait Time", "Staff Attitude", "Medication Error", "Billing Issue", "Facility Cleanliness", "Privacy Concern" + 3. Generate a short_description (2-3 sentences) that captures the main issue and context in BOTH English and Arabic 3. Classify the complaint using the 4-level SHCT taxonomy: a. Select the most appropriate DOMAIN (Level 1) b. Select the most appropriate CATEGORY within that domain (Level 2) @@ -681,6 +680,8 @@ class AIService: {{ "title_en": "concise title in English summarizing the complaint (max 10 words)", "title_ar": "العنوان بالعربية", + "brief_summary_en": "exactly 2-3 word tag/label in English (e.g., 'Wait Time', 'Staff Attitude', 'Billing Issue')", + "brief_summary_ar": "وصف مختصر من 2-3 كلمات بالعربية", "short_description_en": "2-3 sentence summary in English of the complaint that captures the main issue and context", "short_description_ar": "ملخص من 2-3 جمل بالعربية", "severity": "low|medium|high|critical", @@ -747,7 +748,7 @@ class AIService: prompt=prompt, system_prompt=system_prompt, response_format="json_object", - temperature=0.2 # Lower temperature for consistent classification + temperature=0.2, # Lower temperature for consistent classification ) # Parse JSON response @@ -755,32 +756,32 @@ class AIService: # Detect complaint type complaint_type = cls._detect_complaint_type(description + " " + (title or "")) - result['complaint_type'] = complaint_type + result["complaint_type"] = complaint_type # Map AI taxonomy to database objects - if use_taxonomy and 'taxonomy' in result: - taxonomy_mapping = cls._map_ai_taxonomy_to_db(result['taxonomy']) + if use_taxonomy and "taxonomy" in result: + taxonomy_mapping = cls._map_ai_taxonomy_to_db(result["taxonomy"]) # Replace AI taxonomy IDs with database IDs - result['taxonomy_mapping'] = taxonomy_mapping - result['taxonomy'] = result['taxonomy'] # Keep original AI response + result["taxonomy_mapping"] = taxonomy_mapping + result["taxonomy"] = result["taxonomy"] # Keep original AI response # Use provided title if available, otherwise use AI-generated title if title: - result['title'] = title + result["title"] = title # Validate severity - if result.get('severity') not in cls.SEVERITY_CHOICES: - result['severity'] = 'medium' + if result.get("severity") not in cls.SEVERITY_CHOICES: + result["severity"] = "medium" logger.warning(f"Invalid severity, defaulting to medium") # Validate priority - if result.get('priority') not in cls.PRIORITY_CHOICES: - result['priority'] = 'medium' + if result.get("priority") not in cls.PRIORITY_CHOICES: + result["priority"] = "medium" logger.warning(f"Invalid priority, defaulting to medium") # Ensure title exists (for backward compatibility) - if not result.get('title'): - result['title'] = 'Complaint' + if not result.get("title"): + result["title"] = "Complaint" # Cache result for 1 hour cache.set(cache_key, result, timeout=3600) @@ -795,46 +796,46 @@ class AIService: logger.error(f"Failed to parse AI response: {e}") # Return defaults return { - 'title': title or 'Complaint', - 'title_en': title or 'Complaint', - 'title_ar': title or 'شكوى', - 'short_description_en': description[:200] if description else '', - 'short_description_ar': description[:200] if description else '', - 'severity': 'medium', - 'priority': 'medium', - 'category': 'other', - 'subcategory': '', - 'department': '', - 'staff_names': [], - 'primary_staff_name': '', - 'suggested_action_en': '', - 'suggested_action_ar': '', - 'reasoning_en': 'AI analysis failed, using default values', - 'reasoning_ar': 'فشل تحليل الذكاء الاصطناعي، استخدام القيم الافتراضية', - 'taxonomy': None, - 'taxonomy_mapping': None + "title": title or "Complaint", + "title_en": title or "Complaint", + "title_ar": title or "شكوى", + "short_description_en": description[:200] if description else "", + "short_description_ar": description[:200] if description else "", + "severity": "medium", + "priority": "medium", + "category": "other", + "subcategory": "", + "department": "", + "staff_names": [], + "primary_staff_name": "", + "suggested_action_en": "", + "suggested_action_ar": "", + "reasoning_en": "AI analysis failed, using default values", + "reasoning_ar": "فشل تحليل الذكاء الاصطناعي، استخدام القيم الافتراضية", + "taxonomy": None, + "taxonomy_mapping": None, } except AIServiceError as e: logger.error(f"AI service error: {e}") return { - 'title': title or 'Complaint', - 'title_en': title or 'Complaint', - 'title_ar': title or 'شكوى', - 'short_description_en': description[:200] if description else '', - 'short_description_ar': description[:200] if description else '', - 'severity': 'medium', - 'priority': 'medium', - 'category': 'other', - 'subcategory': '', - 'department': '', - 'staff_names': [], - 'primary_staff_name': '', - 'suggested_action_en': '', - 'suggested_action_ar': '', - 'reasoning_en': f'AI service unavailable: {str(e)}', - 'reasoning_ar': f'خدمة الذكاء الاصطناعي غير متوفرة: {str(e)}', - 'taxonomy': None, - 'taxonomy_mapping': None + "title": title or "Complaint", + "title_en": title or "Complaint", + "title_ar": title or "شكوى", + "short_description_en": description[:200] if description else "", + "short_description_ar": description[:200] if description else "", + "severity": "medium", + "priority": "medium", + "category": "other", + "subcategory": "", + "department": "", + "staff_names": [], + "primary_staff_name": "", + "suggested_action_en": "", + "suggested_action_ar": "", + "reasoning_en": f"AI service unavailable: {str(e)}", + "reasoning_ar": f"خدمة الذكاء الاصطناعي غير متوفرة: {str(e)}", + "taxonomy": None, + "taxonomy_mapping": None, } @classmethod @@ -850,25 +851,22 @@ class AIService: """ text = "" - for domain in taxonomy_hierarchy.get('domains', []): + for domain in taxonomy_hierarchy.get("domains", []): text += f"\nDOMAIN: {domain['code']} - {domain['name_en']} ({domain['name_ar']})\n" - for category in domain.get('categories', []): + for category in domain.get("categories", []): text += f" CATEGORY: {category['code']} - {category['name_en']} ({category['name_ar']})\n" - for subcategory in category.get('subcategories', []): + for subcategory in category.get("subcategories", []): text += f" SUBCATEGORY: {subcategory['code']} - {subcategory['name_en']} ({subcategory['name_ar']})\n" - for classification in subcategory.get('classifications', []): + for classification in subcategory.get("classifications", []): text += f" CLASSIFICATION: {classification['code']} - {classification['name_en']} ({classification['name_ar']})\n" return text @classmethod - def classify_sentiment( - cls, - text: str - ) -> Dict[str, Any]: + def classify_sentiment(cls, text: str) -> Dict[str, Any]: """ Classify sentiment of text. @@ -899,10 +897,7 @@ class AIService: try: response = cls.chat_completion( - prompt=prompt, - system_prompt=system_prompt, - response_format="json_object", - temperature=0.1 + prompt=prompt, system_prompt=system_prompt, response_format="json_object", temperature=0.1 ) result = json.loads(response) @@ -910,17 +905,10 @@ class AIService: except (json.JSONDecodeError, AIServiceError) as e: logger.error(f"Sentiment analysis failed: {e}") - return { - 'sentiment': 'neutral', - 'score': 0.0, - 'confidence': 0.0 - } + return {"sentiment": "neutral", "score": 0.0, "confidence": 0.0} @classmethod - def analyze_emotion( - cls, - text: str - ) -> Dict[str, Any]: + def analyze_emotion(cls, text: str) -> Dict[str, Any]: """ Analyze emotion in text to identify primary emotion and intensity. @@ -968,32 +956,29 @@ class AIService: try: response = cls.chat_completion( - prompt=prompt, - system_prompt=system_prompt, - response_format="json_object", - temperature=0.1 + prompt=prompt, system_prompt=system_prompt, response_format="json_object", temperature=0.1 ) result = json.loads(response) # Validate emotion - valid_emotions = ['anger', 'sadness', 'confusion', 'fear', 'neutral'] - if result.get('emotion') not in valid_emotions: - result['emotion'] = 'neutral' + valid_emotions = ["anger", "sadness", "confusion", "fear", "neutral"] + if result.get("emotion") not in valid_emotions: + result["emotion"] = "neutral" logger.warning(f"Invalid emotion detected, defaulting to neutral") # Validate intensity - intensity = float(result.get('intensity', 0.0)) + intensity = float(result.get("intensity", 0.0)) if not (0.0 <= intensity <= 1.0): intensity = max(0.0, min(1.0, intensity)) - result['intensity'] = intensity + result["intensity"] = intensity logger.warning(f"Intensity out of range, clamping to {intensity}") # Validate confidence - confidence = float(result.get('confidence', 0.0)) + confidence = float(result.get("confidence", 0.0)) if not (0.0 <= confidence <= 1.0): confidence = max(0.0, min(1.0, confidence)) - result['confidence'] = confidence + result["confidence"] = confidence logger.warning(f"Confidence out of range, clamping to {confidence}") logger.info(f"Emotion analysis: {result['emotion']}, intensity={intensity}, confidence={confidence}") @@ -1001,11 +986,7 @@ class AIService: except (json.JSONDecodeError, AIServiceError) as e: logger.error(f"Emotion analysis failed: {e}") - return { - 'emotion': 'neutral', - 'intensity': 0.0, - 'confidence': 0.0 - } + return {"emotion": "neutral", "intensity": 0.0, "confidence": 0.0} @classmethod def extract_entities(cls, text: str) -> List[Dict[str, str]]: @@ -1023,16 +1004,15 @@ class AIService: ] }}""" - system_prompt = "You are an expert in bilingual NER (Arabic and English). Extract formal names for database lookup." + system_prompt = ( + "You are an expert in bilingual NER (Arabic and English). Extract formal names for database lookup." + ) try: response = cls.chat_completion( - prompt=prompt, - system_prompt=system_prompt, - response_format="json_object", - temperature=0.0 + prompt=prompt, system_prompt=system_prompt, response_format="json_object", temperature=0.0 ) - return json.loads(response).get('entities', []) + return json.loads(response).get("entities", []) except (json.JSONDecodeError, AIServiceError): return [] @@ -1056,12 +1036,7 @@ class AIService: Create a concise summary that captures the main points.""" try: - response = cls.chat_completion( - prompt=prompt, - system_prompt=system_prompt, - temperature=0.3, - max_tokens=150 - ) + response = cls.chat_completion(prompt=prompt, system_prompt=system_prompt, temperature=0.3, max_tokens=150) return response.strip() @@ -1091,7 +1066,7 @@ class AIService: # Get complaint data title = complaint.title description = complaint.description - complaint_category = complaint.category.name_en if complaint.category else 'other' + complaint_category = complaint.category.name_en if complaint.category else "other" severity = complaint.severity priority = complaint.priority @@ -1137,10 +1112,7 @@ class AIService: try: response = cls.chat_completion( - prompt=prompt, - system_prompt=system_prompt, - response_format="json_object", - temperature=0.3 + prompt=prompt, system_prompt=system_prompt, response_format="json_object", temperature=0.3 ) # Parse JSON response @@ -1148,20 +1120,25 @@ class AIService: # Validate category valid_categories = [ - 'clinical_quality', 'patient_safety', 'service_quality', - 'staff_behavior', 'facility', 'process_improvement', 'other' + "clinical_quality", + "patient_safety", + "service_quality", + "staff_behavior", + "facility", + "process_improvement", + "other", ] - if result.get('category') not in valid_categories: + if result.get("category") not in valid_categories: # Fallback: map complaint category to action category - result['category'] = cls._map_category_to_action_category(complaint_category) + result["category"] = cls._map_category_to_action_category(complaint_category) # Validate severity - if result.get('severity') not in cls.SEVERITY_CHOICES: - result['severity'] = severity # Use complaint severity as fallback + if result.get("severity") not in cls.SEVERITY_CHOICES: + result["severity"] = severity # Use complaint severity as fallback # Validate priority - if result.get('priority') not in cls.PRIORITY_CHOICES: - result['priority'] = priority # Use complaint priority as fallback + if result.get("priority") not in cls.PRIORITY_CHOICES: + result["priority"] = priority # Use complaint priority as fallback logger.info(f"PX Action generated: title={result['title']}, category={result['category']}") return result @@ -1170,23 +1147,23 @@ class AIService: logger.error(f"Failed to parse AI response: {e}") # Return fallback based on complaint data return { - 'title': f'Address: {title}', - 'description': f'Resolve the complaint: {description}', - 'category': cls._map_category_to_action_category(complaint_category), - 'priority': priority, - 'severity': severity, - 'reasoning': 'AI generation failed, using complaint data as fallback' + "title": f"Address: {title}", + "description": f"Resolve the complaint: {description}", + "category": cls._map_category_to_action_category(complaint_category), + "priority": priority, + "severity": severity, + "reasoning": "AI generation failed, using complaint data as fallback", } except AIServiceError as e: logger.error(f"AI service error: {e}") # Return fallback based on complaint data return { - 'title': f'Address: {title}', - 'description': f'Resolve the complaint: {description}', - 'category': cls._map_category_to_action_category(complaint_category), - 'priority': priority, - 'severity': severity, - 'reasoning': f'AI service unavailable: {str(e)}' + "title": f"Address: {title}", + "description": f"Resolve the complaint: {description}", + "category": cls._map_category_to_action_category(complaint_category), + "priority": priority, + "severity": severity, + "reasoning": f"AI service unavailable: {str(e)}", } @classmethod @@ -1201,51 +1178,46 @@ class AIService: PX Action category name """ # Normalize category name (lowercase, remove spaces) - category_lower = complaint_category.lower().replace(' ', '_') + category_lower = complaint_category.lower().replace(" ", "_") # Mapping dictionary mapping = { # Clinical categories - 'clinical': 'clinical_quality', - 'medical': 'clinical_quality', - 'diagnosis': 'clinical_quality', - 'treatment': 'clinical_quality', - 'care': 'clinical_quality', - + "clinical": "clinical_quality", + "medical": "clinical_quality", + "diagnosis": "clinical_quality", + "treatment": "clinical_quality", + "care": "clinical_quality", # Safety categories - 'safety': 'patient_safety', - 'infection': 'patient_safety', - 'risk': 'patient_safety', - 'dangerous': 'patient_safety', - + "safety": "patient_safety", + "infection": "patient_safety", + "risk": "patient_safety", + "dangerous": "patient_safety", # Service quality - 'service': 'service_quality', - 'wait': 'service_quality', - 'waiting': 'service_quality', - 'appointment': 'service_quality', - 'scheduling': 'service_quality', - + "service": "service_quality", + "wait": "service_quality", + "waiting": "service_quality", + "appointment": "service_quality", + "scheduling": "service_quality", # Staff behavior - 'staff': 'staff_behavior', - 'behavior': 'staff_behavior', - 'attitude': 'staff_behavior', - 'rude': 'staff_behavior', - 'communication': 'staff_behavior', - + "staff": "staff_behavior", + "behavior": "staff_behavior", + "attitude": "staff_behavior", + "rude": "staff_behavior", + "communication": "staff_behavior", # Facility - 'facility': 'facility', - 'environment': 'facility', - 'clean': 'facility', - 'cleanliness': 'facility', - 'equipment': 'facility', - 'room': 'facility', - 'bathroom': 'facility', - + "facility": "facility", + "environment": "facility", + "clean": "facility", + "cleanliness": "facility", + "equipment": "facility", + "room": "facility", + "bathroom": "facility", # Process - 'process': 'process_improvement', - 'workflow': 'process_improvement', - 'procedure': 'process_improvement', - 'policy': 'process_improvement', + "process": "process_improvement", + "workflow": "process_improvement", + "procedure": "process_improvement", + "policy": "process_improvement", } # Check for partial matches @@ -1254,7 +1226,7 @@ class AIService: return value # Default to 'other' if no match found - return 'other' + return "other" @classmethod def _detect_complaint_type(cls, text: str) -> str: @@ -1269,30 +1241,98 @@ class AIService: """ # Keywords for appreciation (English and Arabic) appreciation_keywords_en = [ - 'thank', 'thanks', 'excellent', 'great', 'wonderful', 'amazing', - 'appreciate', 'commend', 'outstanding', 'fantastic', 'brilliant', - 'professional', 'caring', 'helpful', 'friendly', 'good', 'nice', - 'impressive', 'exceptional', 'superb', 'pleased', 'satisfied' + "thank", + "thanks", + "excellent", + "great", + "wonderful", + "amazing", + "appreciate", + "commend", + "outstanding", + "fantastic", + "brilliant", + "professional", + "caring", + "helpful", + "friendly", + "good", + "nice", + "impressive", + "exceptional", + "superb", + "pleased", + "satisfied", ] appreciation_keywords_ar = [ - 'شكرا', 'ممتاز', 'رائع', 'بارك', 'مدهش', 'عظيم', - 'أقدر', 'شكر', 'متميز', 'مهني', 'رعاية', 'مفيد', - 'ودود', 'جيد', 'لطيف', 'مبهر', 'استثنائي', 'سعيد', - 'رضا', 'احترافية', 'خدمة ممتازة' + "شكرا", + "ممتاز", + "رائع", + "بارك", + "مدهش", + "عظيم", + "أقدر", + "شكر", + "متميز", + "مهني", + "رعاية", + "مفيد", + "ودود", + "جيد", + "لطيف", + "مبهر", + "استثنائي", + "سعيد", + "رضا", + "احترافية", + "خدمة ممتازة", ] # Keywords for complaints (English and Arabic) complaint_keywords_en = [ - 'problem', 'issue', 'complaint', 'bad', 'terrible', 'awful', - 'disappointed', 'unhappy', 'poor', 'worst', 'unacceptable', - 'rude', 'slow', 'delay', 'wait', 'neglect', 'ignore', - 'angry', 'frustrated', 'dissatisfied', 'concern', 'worried' + "problem", + "issue", + "complaint", + "bad", + "terrible", + "awful", + "disappointed", + "unhappy", + "poor", + "worst", + "unacceptable", + "rude", + "slow", + "delay", + "wait", + "neglect", + "ignore", + "angry", + "frustrated", + "dissatisfied", + "concern", + "worried", ] complaint_keywords_ar = [ - 'مشكلة', 'مشاكل', 'سيء', 'مخيب', 'سيء للغاية', - 'تعيس', 'ضعيف', 'أسوأ', 'غير مقبول', 'فظ', - 'بطيء', 'تأخير', 'انتظار', 'إهمال', 'تجاهل', - 'غاضب', 'محبط', 'غير راضي', 'قلق' + "مشكلة", + "مشاكل", + "سيء", + "مخيب", + "سيء للغاية", + "تعيس", + "ضعيف", + "أسوأ", + "غير مقبول", + "فظ", + "بطيء", + "تأخير", + "انتظار", + "إهمال", + "تجاهل", + "غاضب", + "محبط", + "غير راضي", + "قلق", ] text_lower = text.lower() @@ -1312,31 +1352,32 @@ class AIService: # Get sentiment analysis try: sentiment_result = cls.classify_sentiment(text) - sentiment = sentiment_result.get('sentiment', 'neutral') - sentiment_score = sentiment_result.get('score', 0.0) + sentiment = sentiment_result.get("sentiment", "neutral") + sentiment_score = sentiment_result.get("score", 0.0) logger.info(f"Sentiment analysis: sentiment={sentiment}, score={sentiment_score}") # If sentiment is clearly positive and has appreciation keywords - if sentiment == 'positive' and sentiment_score > 0.5: + if sentiment == "positive" and sentiment_score > 0.5: if appreciation_count >= complaint_count: - return 'appreciation' + return "appreciation" # If sentiment is clearly negative - if sentiment == 'negative' and sentiment_score < -0.3: - return 'complaint' + if sentiment == "negative" and sentiment_score < -0.3: + return "complaint" except Exception as e: logger.warning(f"Sentiment analysis failed, using keyword-based detection: {e}") # Fallback to keyword-based detection if appreciation_count > complaint_count: - return 'appreciation' + return "appreciation" elif complaint_count > appreciation_count: - return 'complaint' + return "complaint" else: # No clear indicators, default to complaint - return 'complaint' + return "complaint" + # Convenience singleton instance ai_service = AIService() diff --git a/apps/core/config_views.py b/apps/core/config_views.py index bd5a50c..865b36c 100644 --- a/apps/core/config_views.py +++ b/apps/core/config_views.py @@ -1,6 +1,7 @@ """ Configuration Console UI views - System configuration management """ + from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.shortcuts import render @@ -10,6 +11,7 @@ from apps.px_action_center.models import PXActionSLAConfig, RoutingRule from apps.complaints.models import OnCallAdminSchedule from apps.callcenter.models import CallRecord from apps.core.decorators import px_admin_required +from apps.accounts.models import User @px_admin_required @@ -19,102 +21,105 @@ def config_dashboard(request): # Get counts sla_configs_count = PXActionSLAConfig.objects.filter(is_active=True).count() routing_rules_count = RoutingRule.objects.filter(is_active=True).count() - hospitals_count = Hospital.objects.filter(status='active').count() + hospitals_count = Hospital.objects.filter(status="active").count() oncall_schedules_count = OnCallAdminSchedule.objects.filter(is_active=True).count() call_records_count = CallRecord.objects.count() + provisional_users_count = User.objects.filter(is_provisional=True).count() context = { - 'sla_configs_count': sla_configs_count, - 'routing_rules_count': routing_rules_count, - 'hospitals_count': hospitals_count, - 'oncall_schedules_count': oncall_schedules_count, - 'call_records_count': call_records_count, + "sla_configs_count": sla_configs_count, + "routing_rules_count": routing_rules_count, + "hospitals_count": hospitals_count, + "oncall_schedules_count": oncall_schedules_count, + "call_records_count": call_records_count, + "provisional_users_count": provisional_users_count, } - return render(request, 'config/dashboard.html', context) + return render(request, "config/dashboard.html", context) @px_admin_required def sla_config_list(request): """SLA configurations list view - PX Admin only""" - queryset = PXActionSLAConfig.objects.select_related('hospital', 'department') + queryset = PXActionSLAConfig.objects.select_related("hospital", "department") # Apply filters - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) - is_active = request.GET.get('is_active') - if is_active == 'true': + is_active = request.GET.get("is_active") + if is_active == "true": queryset = queryset.filter(is_active=True) - elif is_active == 'false': + elif is_active == "false": queryset = queryset.filter(is_active=False) # Ordering - queryset = queryset.order_by('hospital', 'name') + queryset = queryset.order_by("hospital", "name") # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get hospitals for filter - hospitals = Hospital.objects.filter(status='active') + hospitals = Hospital.objects.filter(status="active") context = { - 'page_obj': page_obj, - 'sla_configs': page_obj.object_list, - 'hospitals': hospitals, - 'filters': request.GET, + "page_obj": page_obj, + "sla_configs": page_obj.object_list, + "hospitals": hospitals, + "filters": request.GET, } - return render(request, 'config/sla_config.html', context) + return render(request, "config/sla_config.html", context) @px_admin_required def routing_rules_list(request): """Routing rules list view - PX Admin only""" - queryset = RoutingRule.objects.select_related( - 'hospital', 'department', 'assign_to_user', 'assign_to_department' - ) + queryset = RoutingRule.objects.select_related("hospital", "department", "assign_to_user", "assign_to_department") # Apply filters - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) - is_active = request.GET.get('is_active') - if is_active == 'true': + is_active = request.GET.get("is_active") + if is_active == "true": queryset = queryset.filter(is_active=True) - elif is_active == 'false': + elif is_active == "false": queryset = queryset.filter(is_active=False) # Ordering - queryset = queryset.order_by('-priority', 'name') + queryset = queryset.order_by("-priority", "name") # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get hospitals for filter - hospitals = Hospital.objects.filter(status='active') + hospitals = Hospital.objects.filter(status="active") context = { - 'page_obj': page_obj, - 'routing_rules': page_obj.object_list, - 'hospitals': hospitals, - 'filters': request.GET, + "page_obj": page_obj, + "routing_rules": page_obj.object_list, + "hospitals": hospitals, + "filters": request.GET, } - return render(request, 'config/routing_rules.html', context) + return render(request, "config/routing_rules.html", context) + from django.views.decorators.csrf import csrf_exempt from rich import print + + @csrf_exempt def test(request): import json @@ -122,4 +127,4 @@ def test(request): print(json.loads(request.body)) - return JsonResponse({'status': 'ok'}) \ No newline at end of file + return JsonResponse({"status": "ok"}) diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index 5148253..6f0bd16 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -62,10 +62,20 @@ def sidebar_counts(request): feedback_count = 0 action_count = 0 + # Get unread notification count + notification_count = 0 + if request.user.is_authenticated: + from apps.notifications.models import UserNotification + + notification_count = UserNotification.objects.filter( + user=request.user, is_read=False, is_dismissed=False + ).count() + return { "complaint_count": complaint_count, "feedback_count": feedback_count, "action_count": action_count, + "notification_count": notification_count, "current_hospital": getattr(request, "tenant_hospital", None), "is_px_admin": request.user.is_authenticated and request.user.is_px_admin(), "is_source_user": False, diff --git a/apps/dashboard/admin.py b/apps/dashboard/admin.py new file mode 100644 index 0000000..4363f09 --- /dev/null +++ b/apps/dashboard/admin.py @@ -0,0 +1,93 @@ +""" +Dashboard Admin Configuration + +Admin interface for employee evaluation models. +""" + +from django.contrib import admin + +from .models import EvaluationNote, ComplaintRequest, ReportCompletion, EscalatedComplaintLog, InquiryDetail + + +@admin.register(EvaluationNote) +class EvaluationNoteAdmin(admin.ModelAdmin): + """Admin interface for EvaluationNote model""" + + list_display = ["staff", "category", "sub_category", "count", "note_date", "created_by"] + list_filter = ["category", "note_date", "created_at"] + search_fields = ["staff__first_name", "staff__last_name", "description"] + date_hierarchy = "note_date" + raw_id_fields = ["staff", "created_by"] + + +@admin.register(ComplaintRequest) +class ComplaintRequestAdmin(admin.ModelAdmin): + """Admin interface for ComplaintRequest model""" + + list_display = [ + "staff", + "patient_name", + "filled", + "on_hold", + "from_barcode", + "request_date", + "filling_time_category", + "reason_non_activation", + "hospital", + ] + list_filter = [ + "filled", + "on_hold", + "from_barcode", + "filling_time_category", + "reason_non_activation", + "request_date", + "hospital", + ] + search_fields = ["staff__first_name", "staff__last_name", "patient_name", "file_number", "notes"] + date_hierarchy = "request_date" + raw_id_fields = ["staff", "complaint", "hospital", "complained_department"] + fieldsets = ( + (None, {"fields": ("staff", "complaint", "hospital", "request_date", "request_time")}), + ( + "Patient Information", + {"fields": ("patient_name", "file_number", "complained_department", "incident_date", "phone_number")}, + ), + ("Status", {"fields": ("filled", "on_hold", "not_filled", "from_barcode", "filling_time_category")}), + ("Timeline", {"fields": ("form_sent_at", "form_sent_time", "filled_at", "filled_time")}), + ("Non-Activation", {"fields": ("reason_non_activation", "reason_non_activation_other", "pr_observations")}), + ("Notes", {"fields": ("notes",)}), + ) + + +@admin.register(ReportCompletion) +class ReportCompletionAdmin(admin.ModelAdmin): + """Admin interface for ReportCompletion model""" + + list_display = ["staff", "report_type", "is_completed", "week_start_date", "completed_at"] + list_filter = ["report_type", "is_completed", "week_start_date"] + search_fields = ["staff__first_name", "staff__last_name"] + date_hierarchy = "week_start_date" + raw_id_fields = ["staff"] + + +@admin.register(EscalatedComplaintLog) +class EscalatedComplaintLogAdmin(admin.ModelAdmin): + """Admin interface for EscalatedComplaintLog model""" + + list_display = ["staff", "complaint", "escalation_timing", "is_resolved", "escalated_at"] + list_filter = ["escalation_timing", "is_resolved", "week_start_date"] + search_fields = ["staff__first_name", "staff__last_name", "resolution_notes"] + date_hierarchy = "escalated_at" + raw_id_fields = ["staff", "complaint"] + + +@admin.register(InquiryDetail) +class InquiryDetailAdmin(admin.ModelAdmin): + """Admin interface for InquiryDetail model""" + + list_display = ["staff", "inquiry_type", "is_outgoing", "inquiry_date", "response_time_category"] + list_filter = ["inquiry_type", "is_outgoing", "response_time_category", "inquiry_status", "inquiry_date"] + search_fields = ["staff__first_name", "staff__last_name", "notes"] + date_hierarchy = "inquiry_date" + raw_id_fields = ["staff", "inquiry"] diff --git a/apps/dashboard/migrations/__init__.py b/apps/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/dashboard/models.py b/apps/dashboard/models.py new file mode 100644 index 0000000..d53d11c --- /dev/null +++ b/apps/dashboard/models.py @@ -0,0 +1,417 @@ +""" +Employee Evaluation Models + +Models for tracking employee performance metrics and reports for the +PAD Department Weekly Dashboard evaluation system. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from apps.core.models import UUIDModel, TimeStampedModel + + +class EvaluationNote(UUIDModel, TimeStampedModel): + """ + Notes for employee evaluation tracking. + + Tracks notes categorized by type and sub-category for each staff member. + """ + + CATEGORY_CHOICES = [ + ("non_medical", "Non-Medical"), + ("medical", "Medical"), + ("er", "ER"), + ("hospital", "Hospital"), + ] + + SUBCATEGORY_CHOICES = [ + ("it_app", "IT - App"), + ("lab", "LAB"), + ("doctors_managers_reception", "Doctors/Managers/Reception"), + ("hospital_general", "Hospital"), + ("medical_reports", "Medical Reports"), + ("doctors", "Doctors"), + ("other", "Other"), + ] + + staff = models.ForeignKey( + "accounts.User", + on_delete=models.CASCADE, + related_name="evaluation_notes", + help_text="Staff member this note is for", + ) + + category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, help_text="Note category") + + sub_category = models.CharField(max_length=50, choices=SUBCATEGORY_CHOICES, help_text="Note sub-category") + + count = models.IntegerField(default=1, help_text="Number of notes in this category") + + note_date = models.DateField(help_text="Date the note was recorded") + + description = models.TextField(blank=True, help_text="Optional description") + + created_by = models.ForeignKey( + "accounts.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="notes_created", + help_text="User who created this note entry", + ) + + class Meta: + ordering = ["-note_date", "category", "sub_category"] + verbose_name = "Evaluation Note" + verbose_name_plural = "Evaluation Notes" + indexes = [ + models.Index(fields=["staff", "note_date"]), + models.Index(fields=["category", "sub_category"]), + models.Index(fields=["note_date"]), + ] + + def __str__(self): + return f"{self.staff} - {self.get_category_display()} - {self.note_date}" + + +class ComplaintRequest(UUIDModel, TimeStampedModel): + """ + Tracks complaint request filling and status. + + Monitors whether complaint requests were filled, on hold, + or came from barcode scanning. + """ + + FILLING_TIME_CHOICES = [ + ("same_time", "Same Time"), + ("within_6h", "Within 6 Hours"), + ("6_to_24h", "6 to 24 Hours"), + ("after_1_day", "After 1 Day"), + ("not_mentioned", "Time Not Mentioned"), + ] + + NON_ACTIVATION_REASON_CHOICES = [ + ("converted_to_note", "تم تحويلها ملاحظة (Converted to observation)"), + ("issue_resolved_immediately", "تم حل الاشكالية (Issue resolved immediately)"), + ("not_meeting_conditions", "غير مستوفية للشروط (Does not meet conditions)"), + ("raised_via_cchi", "تم رفع الشكوى عن طريق مجلس الضمان الصحي (Raised via CCHI)"), + ("request_not_activated", "لم يتم تفعيل طلب الشكوى (Request not activated)"), + ("complainant_withdrew", "بناء على طلب المشتكي (Per complainant request)"), + ("complainant_retracted", "المشتكي تنازل عن الشكوى (Complainant retracted)"), + ("duplicate", "مكررة (Duplicate)"), + ("other", "Other"), + ] + + staff = models.ForeignKey( + "accounts.User", + on_delete=models.CASCADE, + related_name="complaint_requests_sent", + help_text="Staff member who sent/filled the request", + ) + + complaint = models.ForeignKey( + "complaints.Complaint", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="complaint_request_records", + help_text="Related complaint (if any)", + ) + + hospital = models.ForeignKey( + "organizations.Hospital", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="complaint_requests", + help_text="Hospital where the request was made", + ) + + patient_name = models.CharField(max_length=200, blank=True, help_text="Patient name") + file_number = models.CharField(max_length=100, blank=True, help_text="Patient file number") + complained_department = models.ForeignKey( + "organizations.Department", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="complaint_requests", + help_text="Department being complained about", + ) + incident_date = models.DateField(null=True, blank=True, help_text="Date of the incident") + phone_number = models.CharField(max_length=20, blank=True, help_text="Patient phone number") + + filled = models.BooleanField(default=False, help_text="Whether the request was filled") + on_hold = models.BooleanField(default=False, help_text="Whether the request is on hold") + not_filled = models.BooleanField(default=False, help_text="Whether the request was not filled") + from_barcode = models.BooleanField(default=False, help_text="Whether the request came from barcode scanning") + + filling_time_category = models.CharField( + max_length=20, choices=FILLING_TIME_CHOICES, default="not_mentioned", help_text="When the request was filled" + ) + + request_date = models.DateField(help_text="Date of the request") + request_time = models.TimeField(null=True, blank=True, help_text="Time of the request") + form_sent_at = models.DateTimeField(null=True, blank=True, help_text="When complaint form was sent to patient") + form_sent_time = models.TimeField(null=True, blank=True, help_text="Time when form was sent") + filled_at = models.DateTimeField(null=True, blank=True, help_text="When the request was filled") + filled_time = models.TimeField(null=True, blank=True, help_text="Time when request was filled") + + reason_non_activation = models.CharField( + max_length=50, choices=NON_ACTIVATION_REASON_CHOICES, blank=True, help_text="Reason complaint was not activated" + ) + reason_non_activation_other = models.CharField(max_length=200, blank=True, help_text="Other reason details") + pr_observations = models.TextField(blank=True, help_text="PR team observations about this request") + + notes = models.TextField(blank=True, help_text="Additional notes about the request") + + class Meta: + ordering = ["-request_date", "-created_at"] + verbose_name = "Complaint Request" + verbose_name_plural = "Complaint Requests" + indexes = [ + models.Index(fields=["staff", "request_date"]), + models.Index(fields=["filled", "on_hold"]), + models.Index(fields=["request_date"]), + models.Index(fields=["hospital", "request_date"]), + models.Index(fields=["reason_non_activation"]), + ] + + def __str__(self): + status = "Filled" if self.filled else "Not Filled" + if self.on_hold: + status = "On Hold" + return f"{self.staff} - {status} - {self.request_date}" + + def mark_as_filled(self): + """Mark the request as filled.""" + from django.utils import timezone + + self.filled = True + self.not_filled = False + self.filled_at = timezone.now() + self.save(update_fields=["filled", "not_filled", "filled_at"]) + + def mark_as_not_filled(self): + """Mark the request as not filled.""" + self.filled = False + self.not_filled = True + self.save(update_fields=["filled", "not_filled"]) + + +class ReportCompletion(UUIDModel, TimeStampedModel): + """ + Tracks weekly report completion for employees. + + Monitors which reports were completed by staff members. + """ + + REPORT_TYPE_CHOICES = [ + ("complaint_report", "Complaint Report"), + ("complaint_request_report", "Complaint Request Report"), + ("observation_report", "Observation Report"), + ("incoming_inquiries_report", "Incoming Inquiries Report"), + ("outgoing_inquiries_report", "Outgoing Inquiries Report"), + ("extension_report", "Extension Report"), + ("escalated_complaints_report", "Escalated Complaints Report"), + ] + + staff = models.ForeignKey( + "accounts.User", + on_delete=models.CASCADE, + related_name="report_completions", + help_text="Staff member who should complete the report", + ) + + report_type = models.CharField(max_length=50, choices=REPORT_TYPE_CHOICES, help_text="Type of report") + + is_completed = models.BooleanField(default=False, help_text="Whether the report is completed") + + completed_at = models.DateTimeField(null=True, blank=True, help_text="When the report was completed") + + week_start_date = models.DateField(help_text="Start date of the week this report is for") + + notes = models.TextField(blank=True, help_text="Notes about the report completion") + + class Meta: + ordering = ["-week_start_date", "report_type"] + verbose_name = "Report Completion" + verbose_name_plural = "Report Completions" + unique_together = [["staff", "report_type", "week_start_date"]] + indexes = [ + models.Index(fields=["staff", "week_start_date"]), + models.Index(fields=["report_type", "is_completed"]), + models.Index(fields=["week_start_date"]), + ] + + def __str__(self): + status = "✓" if self.is_completed else "✗" + return f"{self.staff} - {self.get_report_type_display()} - Week of {self.week_start_date} {status}" + + def mark_completed(self): + """Mark this report as completed.""" + from django.utils import timezone + + self.is_completed = True + self.completed_at = timezone.now() + self.save(update_fields=["is_completed", "completed_at"]) + + def mark_incomplete(self): + """Mark this report as not completed.""" + self.is_completed = False + self.completed_at = None + self.save(update_fields=["is_completed", "completed_at"]) + + @classmethod + def get_completion_percentage(cls, staff, week_start_date): + """ + Get completion percentage for a staff member for a given week. + + Returns: + float: Percentage of completed reports (0-100) + """ + total_reports = len(cls.REPORT_TYPE_CHOICES) + completed_count = cls.objects.filter(staff=staff, week_start_date=week_start_date, is_completed=True).count() + return (completed_count / total_reports) * 100 if total_reports > 0 else 0 + + +class EscalatedComplaintLog(UUIDModel, TimeStampedModel): + """ + Logs escalated complaints with timing information. + + Tracks when complaints were escalated relative to the 72-hour SLA. + """ + + ESCALATION_TIMING_CHOICES = [ + ("before_72h", "Before 72 Hours"), + ("exactly_72h", "72 Hours Exactly"), + ("after_72h", "After 72 Hours"), + ] + + complaint = models.ForeignKey( + "complaints.Complaint", + on_delete=models.CASCADE, + related_name="escalation_logs", + help_text="The escalated complaint", + ) + + staff = models.ForeignKey( + "accounts.User", + on_delete=models.CASCADE, + related_name="escalated_complaints", + help_text="Staff member who the complaint is assigned to", + ) + + escalation_timing = models.CharField( + max_length=20, + choices=ESCALATION_TIMING_CHOICES, + help_text="When the complaint was escalated relative to 72h SLA", + ) + + escalated_at = models.DateTimeField(help_text="When the complaint was escalated") + + is_resolved = models.BooleanField(default=False, help_text="Whether the escalated complaint was resolved") + + resolved_at = models.DateTimeField(null=True, blank=True, help_text="When the escalated complaint was resolved") + + resolution_notes = models.TextField(blank=True, help_text="Notes about the resolution") + + week_start_date = models.DateField(help_text="Start date of the week this escalation is recorded for") + + class Meta: + ordering = ["-escalated_at", "-created_at"] + verbose_name = "Escalated Complaint Log" + verbose_name_plural = "Escalated Complaint Logs" + indexes = [ + models.Index(fields=["staff", "week_start_date"]), + models.Index(fields=["escalation_timing", "is_resolved"]), + models.Index(fields=["week_start_date"]), + ] + + def __str__(self): + status = "Resolved" if self.is_resolved else "Unresolved" + return f"{self.staff} - {self.get_escalation_timing_display()} - {status}" + + def mark_resolved(self, notes=""): + """Mark this escalated complaint as resolved.""" + from django.utils import timezone + + self.is_resolved = True + self.resolved_at = timezone.now() + if notes: + self.resolution_notes = notes + self.save(update_fields=["is_resolved", "resolved_at", "resolution_notes"]) + + +class InquiryDetail(UUIDModel, TimeStampedModel): + """ + Detailed tracking of inquiries with types and statuses. + + Extends the base Inquiry model with evaluation-specific tracking. + """ + + INQUIRY_TYPE_CHOICES = [ + ("contact_doctor", "Contact the doctor"), + ("sick_leave_reports", "Sick-Leave - Medical Reports"), + ("blood_test", "Blood test result"), + ("raise_complaint", "Raise a Complaint"), + ("app_problem", "Problem with the app"), + ("medication", "Ask about medication"), + ("insurance_status", "Insurance request status"), + ("general_question", "General question"), + ("other", "Other"), + ] + + STATUS_CHOICES = [ + ("in_progress", "تحت الإجراء (In Progress)"), + ("contacted", "تم التواصل (Contacted)"), + ("contacted_no_response", "تم التواصل ولم يتم الرد (Contacted No Response)"), + ] + + RESPONSE_TIME_CHOICES = [ + ("24h", "24 Hours"), + ("48h", "48 Hours"), + ("72h", "72 Hours"), + ("more_than_72h", "More than 72 Hours"), + ] + + inquiry = models.OneToOneField( + "complaints.Inquiry", on_delete=models.CASCADE, related_name="evaluation_detail", help_text="Related inquiry" + ) + + staff = models.ForeignKey( + "accounts.User", + on_delete=models.CASCADE, + related_name="inquiry_details", + help_text="Staff member handling the inquiry", + ) + + inquiry_type = models.CharField(max_length=50, choices=INQUIRY_TYPE_CHOICES, help_text="Type of inquiry") + + is_outgoing = models.BooleanField(default=False, help_text="Whether this is an outgoing inquiry") + + response_time_category = models.CharField( + max_length=20, choices=RESPONSE_TIME_CHOICES, blank=True, help_text="Response time category" + ) + + inquiry_status = models.CharField( + max_length=50, choices=STATUS_CHOICES, blank=True, help_text="Current status of the inquiry" + ) + + inquiry_date = models.DateField(help_text="Date of the inquiry") + + notes = models.TextField(blank=True, help_text="Additional notes") + + class Meta: + ordering = ["-inquiry_date", "-created_at"] + verbose_name = "Inquiry Detail" + verbose_name_plural = "Inquiry Details" + indexes = [ + models.Index(fields=["staff", "inquiry_date"]), + models.Index(fields=["inquiry_type", "is_outgoing"]), + models.Index(fields=["inquiry_date"]), + ] + + def __str__(self): + direction = "Outgoing" if self.is_outgoing else "Incoming" + return f"{self.staff} - {direction} - {self.get_inquiry_type_display()} - {self.inquiry_date}" diff --git a/apps/dashboard/urls.py b/apps/dashboard/urls.py index 26a2aee..f9aafb3 100644 --- a/apps/dashboard/urls.py +++ b/apps/dashboard/urls.py @@ -1,32 +1,47 @@ """ Dashboard URLs """ + from django.urls import path from .views import ( - CommandCenterView, my_dashboard, dashboard_bulk_action, - admin_evaluation, admin_evaluation_chart_data, - staff_performance_detail, staff_performance_trends, - department_benchmarks, export_staff_performance, - performance_analytics_api, command_center_api + CommandCenterView, + my_dashboard, + dashboard_bulk_action, + admin_evaluation, + admin_evaluation_chart_data, + staff_performance_detail, + staff_performance_trends, + department_benchmarks, + export_staff_performance, + performance_analytics_api, + command_center_api, + employee_evaluation, + employee_evaluation_data, + complaint_request_list, + complaint_request_export, ) -app_name = 'dashboard' +app_name = "dashboard" urlpatterns = [ - path('', CommandCenterView.as_view(), name='command-center'), - path('api/', command_center_api, name='command_center_api'), - path('my/', my_dashboard, name='my_dashboard'), - path('bulk-action/', dashboard_bulk_action, name='bulk_action'), - + path("", CommandCenterView.as_view(), name="command-center"), + path("api/", command_center_api, name="command_center_api"), + path("my/", my_dashboard, name="my_dashboard"), + path("bulk-action/", dashboard_bulk_action, name="bulk_action"), # Admin Evaluation - path('admin-evaluation/', admin_evaluation, name='admin_evaluation'), - path('admin-evaluation/chart-data/', admin_evaluation_chart_data, name='admin_evaluation_chart_data'), - + path("admin-evaluation/", admin_evaluation, name="admin_evaluation"), + path("admin-evaluation/chart-data/", admin_evaluation_chart_data, name="admin_evaluation_chart_data"), + # Employee Evaluation (PAD Department Weekly Dashboard) + path("employee-evaluation/", employee_evaluation, name="employee_evaluation"), + path("employee-evaluation/data/", employee_evaluation_data, name="employee_evaluation_data"), # Enhanced Staff Performance - path('admin-evaluation/staff//', staff_performance_detail, name='staff_performance_detail'), - path('admin-evaluation/staff//trends/', staff_performance_trends, name='staff_performance_trends'), - path('admin-evaluation/benchmarks/', department_benchmarks, name='department_benchmarks'), - path('admin-evaluation/export/', export_staff_performance, name='export_staff_performance'), - path('admin-evaluation/analytics/', performance_analytics_api, name='performance_analytics_api'), + path("admin-evaluation/staff//", staff_performance_detail, name="staff_performance_detail"), + path("admin-evaluation/staff//trends/", staff_performance_trends, name="staff_performance_trends"), + path("admin-evaluation/benchmarks/", department_benchmarks, name="department_benchmarks"), + path("admin-evaluation/export/", export_staff_performance, name="export_staff_performance"), + path("admin-evaluation/analytics/", performance_analytics_api, name="performance_analytics_api"), + # Step 0 — Complaint Requests Report + path("complaint-requests/", complaint_request_list, name="complaint_request_list"), + path("complaint-requests/export/", complaint_request_export, name="complaint_request_export"), ] diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index e508658..61afcc0 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -1,6 +1,7 @@ """ Dashboard views - PX Command Center and analytics dashboards """ + import json from datetime import timedelta, datetime @@ -16,7 +17,6 @@ from django.utils.translation import gettext_lazy as _ from django.contrib import messages - class CommandCenterView(LoginRequiredMixin, TemplateView): """ PX Command Center Dashboard - Real-time control panel. @@ -28,21 +28,22 @@ class CommandCenterView(LoginRequiredMixin, TemplateView): - Live feed (latest complaints, actions, events) - Enhanced modules (Inquiries, Observations) - Filters (date range, hospital, department) - + Follows the "5-Second Rule": Critical signals dominant and comprehensible within 5 seconds. Uses modular tile/card system with 30-60 second auto-refresh capability. """ - template_name = 'dashboard/command_center.html' + + template_name = "dashboard/command_center.html" def dispatch(self, request, *args, **kwargs): """Check user type and redirect accordingly""" # Redirect Source Users to their dashboard if request.user.is_authenticated and request.user.is_source_user(): - return redirect('px_sources:source_user_dashboard') - + return redirect("px_sources:source_user_dashboard") + # Check PX Admin has selected a hospital before processing request if request.user.is_authenticated and request.user.is_px_admin() and not request.tenant_hospital: - return redirect('core:select_hospital') + return redirect("core:select_hospital") return super().dispatch(request, *args, **kwargs) @@ -77,13 +78,17 @@ class CommandCenterView(LoginRequiredMixin, TemplateView): actions_qs = PXAction.objects.filter(hospital=hospital) if hospital else PXAction.objects.none() surveys_qs = SurveyInstance.objects.all() # Surveys can be viewed across hospitals social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not hospital-specific - calls_qs = CallCenterInteraction.objects.filter(hospital=hospital) if hospital else CallCenterInteraction.objects.none() + calls_qs = ( + CallCenterInteraction.objects.filter(hospital=hospital) + if hospital + else CallCenterInteraction.objects.none() + ) observations_qs = Observation.objects.filter(hospital=hospital) if hospital else Observation.objects.none() elif user.is_hospital_admin() and user.hospital: complaints_qs = Complaint.objects.filter(hospital=user.hospital) inquiries_qs = Inquiry.objects.filter(hospital=user.hospital) actions_qs = PXAction.objects.filter(hospital=user.hospital) - surveys_qs = SurveyInstance.objects.filter(survey_template__hospital=user.hospital) + surveys_qs = SurveyInstance.objects.filter(hospital=user.hospital) social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not hospital-specific calls_qs = CallCenterInteraction.objects.filter(hospital=user.hospital) observations_qs = Observation.objects.filter(hospital=user.hospital) @@ -108,131 +113,115 @@ class CommandCenterView(LoginRequiredMixin, TemplateView): # RED ALERT ITEMS (5-Second Rule) # ======================================== red_alerts = [] - + # Critical complaints - critical_complaints = complaints_qs.filter( - severity='critical', - status__in=['open', 'in_progress'] - ).count() + critical_complaints = complaints_qs.filter(severity="critical", status__in=["open", "in_progress"]).count() if critical_complaints > 0: - red_alerts.append({ - 'type': 'critical_complaints', - 'label': _('Critical Complaints'), - 'value': critical_complaints, - 'icon': 'alert-octagon', - 'color': 'red', - 'url': f"{reverse('complaints:complaint_list')}?severity=critical&status=open,in_progress", - 'priority': 1 - }) - + red_alerts.append( + { + "type": "critical_complaints", + "label": _("Critical Complaints"), + "value": critical_complaints, + "icon": "alert-octagon", + "color": "red", + "url": f"{reverse('complaints:complaint_list')}?severity=critical&status=open,in_progress", + "priority": 1, + } + ) + # Overdue complaints - overdue_complaints = complaints_qs.filter( - is_overdue=True, - status__in=['open', 'in_progress'] - ).count() + overdue_complaints = complaints_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count() if overdue_complaints > 0: - red_alerts.append({ - 'type': 'overdue_complaints', - 'label': _('Overdue Complaints'), - 'value': overdue_complaints, - 'icon': 'clock', - 'color': 'orange', - 'url': f"{reverse('complaints:complaint_list')}?is_overdue=true&status=open,in_progress", - 'priority': 2 - }) - + red_alerts.append( + { + "type": "overdue_complaints", + "label": _("Overdue Complaints"), + "value": overdue_complaints, + "icon": "clock", + "color": "orange", + "url": f"{reverse('complaints:complaint_list')}?is_overdue=true&status=open,in_progress", + "priority": 2, + } + ) + # Escalated actions - escalated_actions = actions_qs.filter( - escalation_level__gt=0, - status__in=['open', 'in_progress'] - ).count() + escalated_actions = actions_qs.filter(escalation_level__gt=0, status__in=["open", "in_progress"]).count() if escalated_actions > 0: - red_alerts.append({ - 'type': 'escalated_actions', - 'label': _('Escalated Actions'), - 'value': escalated_actions, - 'icon': 'arrow-up-circle', - 'color': 'red', - 'url': reverse('actions:action_list'), - 'priority': 3 - }) - + red_alerts.append( + { + "type": "escalated_actions", + "label": _("Escalated Actions"), + "value": escalated_actions, + "icon": "arrow-up-circle", + "color": "red", + "url": reverse("actions:action_list"), + "priority": 3, + } + ) + # Negative surveys in last 24h - negative_surveys_24h = surveys_qs.filter( - is_negative=True, - completed_at__gte=last_24h - ).count() + negative_surveys_24h = surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count() if negative_surveys_24h > 0: - red_alerts.append({ - 'type': 'negative_surveys', - 'label': _('Negative Surveys (24h)'), - 'value': negative_surveys_24h, - 'icon': 'frown', - 'color': 'yellow', - 'url': reverse('surveys:instance_list'), - 'priority': 4 - }) - + red_alerts.append( + { + "type": "negative_surveys", + "label": _("Negative Surveys (24h)"), + "value": negative_surveys_24h, + "icon": "frown", + "color": "yellow", + "url": reverse("surveys:instance_list"), + "priority": 4, + } + ) + # Sort by priority - red_alerts.sort(key=lambda x: x['priority']) - context['red_alerts'] = red_alerts - context['has_red_alerts'] = len(red_alerts) > 0 + red_alerts.sort(key=lambda x: x["priority"]) + context["red_alerts"] = red_alerts + context["has_red_alerts"] = len(red_alerts) > 0 # ======================================== # COMPLAINTS MODULE DATA # ======================================== complaints_current = complaints_qs.filter(created_at__gte=last_30d).count() - complaints_previous = complaints_qs.filter( - created_at__gte=last_60d, - created_at__lt=last_30d - ).count() + complaints_previous = complaints_qs.filter(created_at__gte=last_60d, created_at__lt=last_30d).count() complaints_variance = 0 if complaints_previous > 0: complaints_variance = round(((complaints_current - complaints_previous) / complaints_previous) * 100, 1) - + # Resolution time calculation - resolved_complaints = complaints_qs.filter( - status='closed', - closed_at__isnull=False, - created_at__gte=last_30d - ) + resolved_complaints = complaints_qs.filter(status="closed", closed_at__isnull=False, created_at__gte=last_30d) avg_resolution_hours = 0 if resolved_complaints.exists(): - total_hours = sum( - (c.closed_at - c.created_at).total_seconds() / 3600 - for c in resolved_complaints - ) + total_hours = sum((c.closed_at - c.created_at).total_seconds() / 3600 for c in resolved_complaints) avg_resolution_hours = round(total_hours / resolved_complaints.count(), 1) - + # Complaints by severity for donut chart complaints_by_severity = { - 'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(), - 'high': complaints_qs.filter(severity='high', status__in=['open', 'in_progress']).count(), - 'medium': complaints_qs.filter(severity='medium', status__in=['open', 'in_progress']).count(), - 'low': complaints_qs.filter(severity='low', status__in=['open', 'in_progress']).count(), + "critical": complaints_qs.filter(severity="critical", status__in=["open", "in_progress"]).count(), + "high": complaints_qs.filter(severity="high", status__in=["open", "in_progress"]).count(), + "medium": complaints_qs.filter(severity="medium", status__in=["open", "in_progress"]).count(), + "low": complaints_qs.filter(severity="low", status__in=["open", "in_progress"]).count(), } - + # Complaints by department for heatmap complaints_by_department = list( - complaints_qs.filter( - status__in=['open', 'in_progress'], - department__isnull=False - ).values('department__name').annotate( - count=Count('id') - ).order_by('-count')[:10] + complaints_qs.filter(status__in=["open", "in_progress"], department__isnull=False) + .values("department__name") + .annotate(count=Count("id")) + .order_by("-count")[:10] ) - context['complaints_module'] = { - 'total_active': complaints_qs.filter(status__in=['open', 'in_progress']).count(), - 'current_period': complaints_current, - 'previous_period': complaints_previous, - 'variance': complaints_variance, - 'variance_direction': 'up' if complaints_variance > 0 else 'down' if complaints_variance < 0 else 'neutral', - 'avg_resolution_hours': avg_resolution_hours, - 'overdue': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(), - 'by_severity': complaints_by_severity, - 'by_department': complaints_by_department, - 'critical_new': complaints_qs.filter(severity='critical', created_at__gte=last_24h).count(), + context["complaints_module"] = { + "total_active": complaints_qs.filter(status__in=["open", "in_progress"]).count(), + "current_period": complaints_current, + "previous_period": complaints_previous, + "variance": complaints_variance, + "variance_direction": "up" if complaints_variance > 0 else "down" if complaints_variance < 0 else "neutral", + "avg_resolution_hours": avg_resolution_hours, + "overdue": complaints_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count(), + "by_severity": complaints_by_severity, + "by_department": complaints_by_department, + "critical_new": complaints_qs.filter(severity="critical", created_at__gte=last_24h).count(), } # ======================================== @@ -240,120 +229,111 @@ class CommandCenterView(LoginRequiredMixin, TemplateView): # ======================================== surveys_completed_30d = surveys_qs.filter(completed_at__gte=last_30d) total_surveys_30d = surveys_completed_30d.count() - + # Calculate average satisfaction - avg_satisfaction = surveys_completed_30d.filter( - total_score__isnull=False - ).aggregate(Avg('total_score'))['total_score__avg'] or 0 - + avg_satisfaction = ( + surveys_completed_30d.filter(total_score__isnull=False).aggregate(Avg("total_score"))["total_score__avg"] + or 0 + ) + # NPS-style calculation (promoters - detractors) positive_count = surveys_completed_30d.filter(is_negative=False).count() negative_count = surveys_completed_30d.filter(is_negative=True).count() nps_score = 0 if total_surveys_30d > 0: nps_score = round(((positive_count - negative_count) / total_surveys_30d) * 100) - + # Response rate (completed vs sent in last 30 days) surveys_sent_30d = surveys_qs.filter(sent_at__gte=last_30d).count() response_rate = 0 if surveys_sent_30d > 0: response_rate = round((total_surveys_30d / surveys_sent_30d) * 100, 1) - - context['survey_module'] = { - 'avg_satisfaction': round(avg_satisfaction, 1), - 'nps_score': nps_score, - 'response_rate': response_rate, - 'total_completed': total_surveys_30d, - 'positive_count': positive_count, - 'negative_count': negative_count, - 'neutral_count': total_surveys_30d - positive_count - negative_count, - 'negative_24h': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(), + + context["survey_module"] = { + "avg_satisfaction": round(avg_satisfaction, 1), + "nps_score": nps_score, + "response_rate": response_rate, + "total_completed": total_surveys_30d, + "positive_count": positive_count, + "negative_count": negative_count, + "neutral_count": total_surveys_30d - positive_count - negative_count, + "negative_24h": surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(), } # ======================================== # PX ACTIONS MODULE DATA # ======================================== - actions_open = actions_qs.filter(status='open').count() - actions_in_progress = actions_qs.filter(status='in_progress').count() - actions_pending_approval = actions_qs.filter(status='pending_approval').count() - actions_closed_30d = actions_qs.filter(status='closed', closed_at__gte=last_30d).count() - + actions_open = actions_qs.filter(status="open").count() + actions_in_progress = actions_qs.filter(status="in_progress").count() + actions_pending_approval = actions_qs.filter(status="pending_approval").count() + actions_closed_30d = actions_qs.filter(status="closed", closed_at__gte=last_30d).count() + # Time to close calculation - closed_actions = actions_qs.filter( - status='closed', - closed_at__isnull=False, - created_at__gte=last_30d - ) + closed_actions = actions_qs.filter(status="closed", closed_at__isnull=False, created_at__gte=last_30d) avg_time_to_close_hours = 0 if closed_actions.exists(): - total_hours = sum( - (a.closed_at - a.created_at).total_seconds() / 3600 - for a in closed_actions - ) + total_hours = sum((a.closed_at - a.created_at).total_seconds() / 3600 for a in closed_actions) avg_time_to_close_hours = round(total_hours / closed_actions.count(), 1) - + # Actions by source for breakdown actions_by_source = list( - actions_qs.filter( - status__in=['open', 'in_progress'] - ).values('source_type').annotate( - count=Count('id') - ).order_by('-count') + actions_qs.filter(status__in=["open", "in_progress"]) + .values("source_type") + .annotate(count=Count("id")) + .order_by("-count") ) - - context['actions_module'] = { - 'open': actions_open, - 'in_progress': actions_in_progress, - 'pending_approval': actions_pending_approval, - 'overdue': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(), - 'escalated': actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count(), - 'closed_30d': actions_closed_30d, - 'avg_time_to_close_hours': avg_time_to_close_hours, - 'by_source': actions_by_source, - 'new_today': actions_qs.filter(created_at__gte=last_24h).count(), + + context["actions_module"] = { + "open": actions_open, + "in_progress": actions_in_progress, + "pending_approval": actions_pending_approval, + "overdue": actions_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count(), + "escalated": actions_qs.filter(escalation_level__gt=0, status__in=["open", "in_progress"]).count(), + "closed_30d": actions_closed_30d, + "avg_time_to_close_hours": avg_time_to_close_hours, + "by_source": actions_by_source, + "new_today": actions_qs.filter(created_at__gte=last_24h).count(), } # ======================================== # INQUIRIES MODULE DATA # ======================================== - inquiries_open = inquiries_qs.filter(status='open').count() - inquiries_in_progress = inquiries_qs.filter(status='in_progress').count() - inquiries_resolved_30d = inquiries_qs.filter(status='resolved', updated_at__gte=last_30d).count() - - context['inquiries_module'] = { - 'open': inquiries_open, - 'in_progress': inquiries_in_progress, - 'total_active': inquiries_open + inquiries_in_progress, - 'resolved_30d': inquiries_resolved_30d, - 'new_24h': inquiries_qs.filter(created_at__gte=last_24h).count(), + inquiries_open = inquiries_qs.filter(status="open").count() + inquiries_in_progress = inquiries_qs.filter(status="in_progress").count() + inquiries_resolved_30d = inquiries_qs.filter(status="resolved", updated_at__gte=last_30d).count() + + context["inquiries_module"] = { + "open": inquiries_open, + "in_progress": inquiries_in_progress, + "total_active": inquiries_open + inquiries_in_progress, + "resolved_30d": inquiries_resolved_30d, + "new_24h": inquiries_qs.filter(created_at__gte=last_24h).count(), } # ======================================== # OBSERVATIONS MODULE DATA # ======================================== - observations_new = observations_qs.filter(status='new').count() - observations_in_progress = observations_qs.filter(status='in_progress').count() + observations_new = observations_qs.filter(status="new").count() + observations_in_progress = observations_qs.filter(status="in_progress").count() observations_critical = observations_qs.filter( - severity='critical', - status__in=['new', 'triaged', 'assigned', 'in_progress'] + severity="critical", status__in=["new", "triaged", "assigned", "in_progress"] ).count() - + # Observations by category observations_by_category = list( - observations_qs.filter( - status__in=['new', 'triaged', 'assigned', 'in_progress'] - ).values('category__name_en').annotate( - count=Count('id') - ).order_by('-count')[:5] + observations_qs.filter(status__in=["new", "triaged", "assigned", "in_progress"]) + .values("category__name_en") + .annotate(count=Count("id")) + .order_by("-count")[:5] ) - - context['observations_module'] = { - 'new': observations_new, - 'in_progress': observations_in_progress, - 'critical': observations_critical, - 'total_active': observations_new + observations_in_progress, - 'resolved_30d': observations_qs.filter(status='resolved', resolved_at__gte=last_30d).count(), - 'by_category': observations_by_category, + + context["observations_module"] = { + "new": observations_new, + "in_progress": observations_in_progress, + "critical": observations_critical, + "total_active": observations_new + observations_in_progress, + "resolved_30d": observations_qs.filter(status="resolved", resolved_at__gte=last_30d).count(), + "by_category": observations_by_category, } # ======================================== @@ -362,59 +342,68 @@ class CommandCenterView(LoginRequiredMixin, TemplateView): calls_7d = calls_qs.filter(call_started_at__gte=last_7d) total_calls = calls_7d.count() low_rating_calls = calls_7d.filter(is_low_rating=True).count() - - context['calls_module'] = { - 'total_7d': total_calls, - 'low_ratings': low_rating_calls, - 'satisfaction_rate': round(((total_calls - low_rating_calls) / total_calls * 100), 1) if total_calls > 0 else 0, + + context["calls_module"] = { + "total_7d": total_calls, + "low_ratings": low_rating_calls, + "satisfaction_rate": round(((total_calls - low_rating_calls) / total_calls * 100), 1) + if total_calls > 0 + else 0, } # ======================================== # LEGACY STATS (for backward compatibility) # ======================================== - context['stats'] = { - 'total_complaints': context['complaints_module']['total_active'], - 'avg_resolution_time': f"{avg_resolution_hours}h", - 'satisfaction_score': round(avg_satisfaction, 0), - 'active_actions': context['actions_module']['open'] + context['actions_module']['in_progress'], - 'new_today': context['actions_module']['new_today'], + context["stats"] = { + "total_complaints": context["complaints_module"]["total_active"], + "avg_resolution_time": f"{avg_resolution_hours}h", + "satisfaction_score": round(avg_satisfaction, 0), + "active_actions": context["actions_module"]["open"] + context["actions_module"]["in_progress"], + "new_today": context["actions_module"]["new_today"], } # ======================================== # LATEST ITEMS FOR LIVE FEED # ======================================== # Latest high severity complaints - context['latest_complaints'] = complaints_qs.filter( - severity__in=['high', 'critical'] - ).select_related('patient', 'hospital', 'department').order_by('-created_at')[:5] + context["latest_complaints"] = ( + complaints_qs.filter(severity__in=["high", "critical"]) + .select_related("patient", "hospital", "department") + .order_by("-created_at")[:5] + ) # Latest escalated actions - context['latest_actions'] = actions_qs.filter( - escalation_level__gt=0 - ).select_related('hospital', 'assigned_to').order_by('-escalated_at')[:5] + context["latest_actions"] = ( + actions_qs.filter(escalation_level__gt=0) + .select_related("hospital", "assigned_to") + .order_by("-escalated_at")[:5] + ) # Latest inquiries - context['latest_inquiries'] = inquiries_qs.filter( - status__in=['open', 'in_progress'] - ).select_related('patient', 'hospital', 'department').order_by('-created_at')[:5] + context["latest_inquiries"] = ( + inquiries_qs.filter(status__in=["open", "in_progress"]) + .select_related("patient", "hospital", "department") + .order_by("-created_at")[:5] + ) # Latest observations - context['latest_observations'] = observations_qs.filter( - status__in=['new', 'triaged', 'assigned'] - ).select_related('hospital', 'category').order_by('-created_at')[:5] + context["latest_observations"] = ( + observations_qs.filter(status__in=["new", "triaged", "assigned"]) + .select_related("hospital", "category") + .order_by("-created_at")[:5] + ) # Latest integration events - context['latest_events'] = InboundEvent.objects.filter( - status='processed' - ).select_related().order_by('-processed_at')[:10] + context["latest_events"] = ( + InboundEvent.objects.filter(status="processed").select_related().order_by("-processed_at")[:10] + ) # ======================================== # PHYSICIAN LEADERBOARD # ======================================== - current_month_ratings = PhysicianMonthlyRating.objects.filter( - year=now.year, - month=now.month - ).select_related('staff', 'staff__hospital', 'staff__department') + current_month_ratings = PhysicianMonthlyRating.objects.filter(year=now.year, month=now.month).select_related( + "staff", "staff__hospital", "staff__department" + ) # Filter by user role if user.is_hospital_admin() and user.hospital: @@ -423,38 +412,38 @@ class CommandCenterView(LoginRequiredMixin, TemplateView): current_month_ratings = current_month_ratings.filter(staff__department=user.department) # Top 5 staff this month - context['top_physicians'] = current_month_ratings.order_by('-average_rating')[:5] + context["top_physicians"] = current_month_ratings.order_by("-average_rating")[:5] # Staff stats physician_stats = current_month_ratings.aggregate( - total_physicians=Count('id'), - avg_rating=Avg('average_rating'), - total_surveys=Count('total_surveys') + total_physicians=Count("id"), avg_rating=Avg("average_rating"), total_surveys=Count("total_surveys") ) - context['physician_stats'] = physician_stats + context["physician_stats"] = physician_stats # ======================================== # CHART DATA # ======================================== - context['chart_data'] = { - 'complaints_trend': json.dumps(self.get_complaints_trend(complaints_qs, last_30d)), - 'complaints_by_severity': json.dumps(complaints_by_severity), - 'survey_satisfaction': avg_satisfaction, - 'nps_trend': json.dumps(self.get_nps_trend(surveys_qs, last_30d)), - 'actions_funnel': json.dumps({ - 'open': actions_open, - 'in_progress': actions_in_progress, - 'pending_approval': actions_pending_approval, - 'closed': actions_closed_30d, - }), + context["chart_data"] = { + "complaints_trend": json.dumps(self.get_complaints_trend(complaints_qs, last_30d)), + "complaints_by_severity": json.dumps(complaints_by_severity), + "survey_satisfaction": avg_satisfaction, + "nps_trend": json.dumps(self.get_nps_trend(surveys_qs, last_30d)), + "actions_funnel": json.dumps( + { + "open": actions_open, + "in_progress": actions_in_progress, + "pending_approval": actions_pending_approval, + "closed": actions_closed_30d, + } + ), } # Add hospital context - context['current_hospital'] = self.request.tenant_hospital - context['is_px_admin'] = user.is_px_admin() - + context["current_hospital"] = self.request.tenant_hospital + context["is_px_admin"] = user.is_px_admin() + # Last updated timestamp - context['last_updated'] = now.strftime('%Y-%m-%d %H:%M:%S') + context["last_updated"] = now.strftime("%Y-%m-%d %H:%M:%S") return context @@ -464,13 +453,8 @@ class CommandCenterView(LoginRequiredMixin, TemplateView): data = [] for i in range(30): date = start_date + timedelta(days=i) - count = queryset.filter( - created_at__date=date.date() - ).count() - data.append({ - 'date': date.strftime('%Y-%m-%d'), - 'count': count - }) + count = queryset.filter(created_at__date=date.date()).count() + data.append({"date": date.strftime("%Y-%m-%d"), "count": count}) return data def get_nps_trend(self, queryset, start_date): @@ -486,25 +470,24 @@ class CommandCenterView(LoginRequiredMixin, TemplateView): nps = round(((positive - negative) / total) * 100) else: nps = 0 - data.append({ - 'date': date.strftime('%Y-%m-%d'), - 'nps': nps - }) + data.append({"date": date.strftime("%Y-%m-%d"), "nps": nps}) return data def get_survey_satisfaction(self, queryset, start_date): """Get survey satisfaction averages""" - return queryset.filter( - completed_at__gte=start_date, - total_score__isnull=False - ).aggregate(Avg('total_score'))['total_score__avg'] or 0 + return ( + queryset.filter(completed_at__gte=start_date, total_score__isnull=False).aggregate(Avg("total_score"))[ + "total_score__avg" + ] + or 0 + ) @login_required def my_dashboard(request): """ My Dashboard - Personal view of all assigned items. - + Shows: - Summary cards with statistics - Tabbed interface for 6 model types: @@ -522,37 +505,38 @@ def my_dashboard(request): """ # Redirect Source Users to their dashboard if request.user.is_source_user(): - return redirect('px_sources:source_user_dashboard') - + return redirect("px_sources:source_user_dashboard") + user = request.user - + # Get selected hospital for PX Admins (from middleware) - selected_hospital = getattr(request, 'tenant_hospital', None) - + selected_hospital = getattr(request, "tenant_hospital", None) + # Get date range filter - date_range_days = int(request.GET.get('date_range', 30)) + date_range_days = int(request.GET.get("date_range", 30)) if date_range_days == -1: # All time start_date = None else: start_date = timezone.now() - timedelta(days=date_range_days) - + # Get active tab - active_tab = request.GET.get('tab', 'complaints') - + active_tab = request.GET.get("tab", "complaints") + # Get search query - search_query = request.GET.get('search', '') - + search_query = request.GET.get("search", "") + # Get status filter - status_filter = request.GET.get('status', '') - + status_filter = request.GET.get("status", "") + # Get priority/severity filter - priority_filter = request.GET.get('priority', '') - + priority_filter = request.GET.get("priority", "") + # Build querysets for all models querysets = {} - + # 1. Complaints from apps.complaints.models import Complaint + complaints_qs = Complaint.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: @@ -560,20 +544,18 @@ def my_dashboard(request): if start_date: complaints_qs = complaints_qs.filter(created_at__gte=start_date) if search_query: - complaints_qs = complaints_qs.filter( - Q(title__icontains=search_query) | - Q(description__icontains=search_query) - ) + complaints_qs = complaints_qs.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query)) if status_filter: complaints_qs = complaints_qs.filter(status=status_filter) if priority_filter: complaints_qs = complaints_qs.filter(severity=priority_filter) - querysets['complaints'] = complaints_qs.select_related( - 'patient', 'hospital', 'department', 'source', 'created_by' - ).order_by('-created_at') - + querysets["complaints"] = complaints_qs.select_related( + "patient", "hospital", "department", "source", "created_by" + ).order_by("-created_at") + # 2. Inquiries from apps.complaints.models import Inquiry + inquiries_qs = Inquiry.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: @@ -581,18 +563,14 @@ def my_dashboard(request): if start_date: inquiries_qs = inquiries_qs.filter(created_at__gte=start_date) if search_query: - inquiries_qs = inquiries_qs.filter( - Q(subject__icontains=search_query) | - Q(message__icontains=search_query) - ) + inquiries_qs = inquiries_qs.filter(Q(subject__icontains=search_query) | Q(message__icontains=search_query)) if status_filter: inquiries_qs = inquiries_qs.filter(status=status_filter) - querysets['inquiries'] = inquiries_qs.select_related( - 'patient', 'hospital', 'department' - ).order_by('-created_at') - + querysets["inquiries"] = inquiries_qs.select_related("patient", "hospital", "department").order_by("-created_at") + # 3. Observations from apps.observations.models import Observation + observations_qs = Observation.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: @@ -601,19 +579,19 @@ def my_dashboard(request): observations_qs = observations_qs.filter(created_at__gte=start_date) if search_query: observations_qs = observations_qs.filter( - Q(title__icontains=search_query) | - Q(description__icontains=search_query) + Q(title__icontains=search_query) | Q(description__icontains=search_query) ) if status_filter: observations_qs = observations_qs.filter(status=status_filter) if priority_filter: observations_qs = observations_qs.filter(severity=priority_filter) - querysets['observations'] = observations_qs.select_related( - 'hospital', 'assigned_department' - ).order_by('-created_at') - + querysets["observations"] = observations_qs.select_related("hospital", "assigned_department").order_by( + "-created_at" + ) + # 4. PX Actions from apps.px_action_center.models import PXAction + actions_qs = PXAction.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: @@ -621,20 +599,16 @@ def my_dashboard(request): if start_date: actions_qs = actions_qs.filter(created_at__gte=start_date) if search_query: - actions_qs = actions_qs.filter( - Q(title__icontains=search_query) | - Q(description__icontains=search_query) - ) + actions_qs = actions_qs.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query)) if status_filter: actions_qs = actions_qs.filter(status=status_filter) if priority_filter: actions_qs = actions_qs.filter(severity=priority_filter) - querysets['actions'] = actions_qs.select_related( - 'hospital', 'department', 'approved_by' - ).order_by('-created_at') - + querysets["actions"] = actions_qs.select_related("hospital", "department", "approved_by").order_by("-created_at") + # 5. QI Project Tasks from apps.projects.models import QIProjectTask + tasks_qs = QIProjectTask.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins (via project) if selected_hospital: @@ -642,16 +616,14 @@ def my_dashboard(request): if start_date: tasks_qs = tasks_qs.filter(created_at__gte=start_date) if search_query: - tasks_qs = tasks_qs.filter( - Q(title__icontains=search_query) | - Q(description__icontains=search_query) - ) + tasks_qs = tasks_qs.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query)) if status_filter: tasks_qs = tasks_qs.filter(status=status_filter) - querysets['tasks'] = tasks_qs.select_related('project').order_by('-created_at') - + querysets["tasks"] = tasks_qs.select_related("project").order_by("-created_at") + # 6. Feedback from apps.feedback.models import Feedback + feedback_qs = Feedback.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: @@ -659,176 +631,164 @@ def my_dashboard(request): if start_date: feedback_qs = feedback_qs.filter(created_at__gte=start_date) if search_query: - feedback_qs = feedback_qs.filter( - Q(title__icontains=search_query) | - Q(message__icontains=search_query) - ) + feedback_qs = feedback_qs.filter(Q(title__icontains=search_query) | Q(message__icontains=search_query)) if status_filter: feedback_qs = feedback_qs.filter(status=status_filter) if priority_filter: feedback_qs = feedback_qs.filter(priority=priority_filter) - querysets['feedback'] = feedback_qs.select_related( - 'hospital', 'department', 'patient' - ).order_by('-created_at') - + querysets["feedback"] = feedback_qs.select_related("hospital", "department", "patient").order_by("-created_at") + # Calculate statistics stats = {} - total_stats = { - 'total': 0, - 'open': 0, - 'in_progress': 0, - 'resolved': 0, - 'closed': 0, - 'overdue': 0 - } - + total_stats = {"total": 0, "open": 0, "in_progress": 0, "resolved": 0, "closed": 0, "overdue": 0} + # Complaints stats - complaints_open = querysets['complaints'].filter(status='open').count() - complaints_in_progress = querysets['complaints'].filter(status='in_progress').count() - complaints_resolved = querysets['complaints'].filter(status='resolved').count() - complaints_closed = querysets['complaints'].filter(status='closed').count() - complaints_overdue = querysets['complaints'].filter(is_overdue=True).count() - stats['complaints'] = { - 'total': querysets['complaints'].count(), - 'open': complaints_open, - 'in_progress': complaints_in_progress, - 'resolved': complaints_resolved, - 'closed': complaints_closed, - 'overdue': complaints_overdue + complaints_open = querysets["complaints"].filter(status="open").count() + complaints_in_progress = querysets["complaints"].filter(status="in_progress").count() + complaints_resolved = querysets["complaints"].filter(status="resolved").count() + complaints_closed = querysets["complaints"].filter(status="closed").count() + complaints_overdue = querysets["complaints"].filter(is_overdue=True).count() + stats["complaints"] = { + "total": querysets["complaints"].count(), + "open": complaints_open, + "in_progress": complaints_in_progress, + "resolved": complaints_resolved, + "closed": complaints_closed, + "overdue": complaints_overdue, } - total_stats['total'] += stats['complaints']['total'] - total_stats['open'] += complaints_open - total_stats['in_progress'] += complaints_in_progress - total_stats['resolved'] += complaints_resolved - total_stats['closed'] += complaints_closed - total_stats['overdue'] += complaints_overdue - + total_stats["total"] += stats["complaints"]["total"] + total_stats["open"] += complaints_open + total_stats["in_progress"] += complaints_in_progress + total_stats["resolved"] += complaints_resolved + total_stats["closed"] += complaints_closed + total_stats["overdue"] += complaints_overdue + # Inquiries stats - inquiries_open = querysets['inquiries'].filter(status='open').count() - inquiries_in_progress = querysets['inquiries'].filter(status='in_progress').count() - inquiries_resolved = querysets['inquiries'].filter(status='resolved').count() - inquiries_closed = querysets['inquiries'].filter(status='closed').count() - stats['inquiries'] = { - 'total': querysets['inquiries'].count(), - 'open': inquiries_open, - 'in_progress': inquiries_in_progress, - 'resolved': inquiries_resolved, - 'closed': inquiries_closed, - 'overdue': 0 + inquiries_open = querysets["inquiries"].filter(status="open").count() + inquiries_in_progress = querysets["inquiries"].filter(status="in_progress").count() + inquiries_resolved = querysets["inquiries"].filter(status="resolved").count() + inquiries_closed = querysets["inquiries"].filter(status="closed").count() + stats["inquiries"] = { + "total": querysets["inquiries"].count(), + "open": inquiries_open, + "in_progress": inquiries_in_progress, + "resolved": inquiries_resolved, + "closed": inquiries_closed, + "overdue": 0, } - total_stats['total'] += stats['inquiries']['total'] - total_stats['open'] += inquiries_open - total_stats['in_progress'] += inquiries_in_progress - total_stats['resolved'] += inquiries_resolved - total_stats['closed'] += inquiries_closed - + total_stats["total"] += stats["inquiries"]["total"] + total_stats["open"] += inquiries_open + total_stats["in_progress"] += inquiries_in_progress + total_stats["resolved"] += inquiries_resolved + total_stats["closed"] += inquiries_closed + # Observations stats - observations_open = querysets['observations'].filter(status='open').count() - observations_in_progress = querysets['observations'].filter(status='in_progress').count() - observations_closed = querysets['observations'].filter(status='closed').count() + observations_open = querysets["observations"].filter(status="open").count() + observations_in_progress = querysets["observations"].filter(status="in_progress").count() + observations_closed = querysets["observations"].filter(status="closed").count() # Observations don't have is_overdue field - set to 0 observations_overdue = 0 - stats['observations'] = { - 'total': querysets['observations'].count(), - 'open': observations_open, - 'in_progress': observations_in_progress, - 'resolved': 0, - 'closed': observations_closed, - 'overdue': observations_overdue + stats["observations"] = { + "total": querysets["observations"].count(), + "open": observations_open, + "in_progress": observations_in_progress, + "resolved": 0, + "closed": observations_closed, + "overdue": observations_overdue, } - total_stats['total'] += stats['observations']['total'] - total_stats['open'] += observations_open - total_stats['in_progress'] += observations_in_progress - total_stats['closed'] += observations_closed - total_stats['overdue'] += observations_overdue - + total_stats["total"] += stats["observations"]["total"] + total_stats["open"] += observations_open + total_stats["in_progress"] += observations_in_progress + total_stats["closed"] += observations_closed + total_stats["overdue"] += observations_overdue + # PX Actions stats - actions_open = querysets['actions'].filter(status='open').count() - actions_in_progress = querysets['actions'].filter(status='in_progress').count() - actions_closed = querysets['actions'].filter(status='closed').count() - actions_overdue = querysets['actions'].filter(is_overdue=True).count() - stats['actions'] = { - 'total': querysets['actions'].count(), - 'open': actions_open, - 'in_progress': actions_in_progress, - 'resolved': 0, - 'closed': actions_closed, - 'overdue': actions_overdue + actions_open = querysets["actions"].filter(status="open").count() + actions_in_progress = querysets["actions"].filter(status="in_progress").count() + actions_closed = querysets["actions"].filter(status="closed").count() + actions_overdue = querysets["actions"].filter(is_overdue=True).count() + stats["actions"] = { + "total": querysets["actions"].count(), + "open": actions_open, + "in_progress": actions_in_progress, + "resolved": 0, + "closed": actions_closed, + "overdue": actions_overdue, } - total_stats['total'] += stats['actions']['total'] - total_stats['open'] += actions_open - total_stats['in_progress'] += actions_in_progress - total_stats['closed'] += actions_closed - total_stats['overdue'] += actions_overdue - + total_stats["total"] += stats["actions"]["total"] + total_stats["open"] += actions_open + total_stats["in_progress"] += actions_in_progress + total_stats["closed"] += actions_closed + total_stats["overdue"] += actions_overdue + # Tasks stats - tasks_open = querysets['tasks'].filter(status='open').count() - tasks_in_progress = querysets['tasks'].filter(status='in_progress').count() - tasks_closed = querysets['tasks'].filter(status='closed').count() - stats['tasks'] = { - 'total': querysets['tasks'].count(), - 'open': tasks_open, - 'in_progress': tasks_in_progress, - 'resolved': 0, - 'closed': tasks_closed, - 'overdue': 0 + tasks_open = querysets["tasks"].filter(status="open").count() + tasks_in_progress = querysets["tasks"].filter(status="in_progress").count() + tasks_closed = querysets["tasks"].filter(status="closed").count() + stats["tasks"] = { + "total": querysets["tasks"].count(), + "open": tasks_open, + "in_progress": tasks_in_progress, + "resolved": 0, + "closed": tasks_closed, + "overdue": 0, } - total_stats['total'] += stats['tasks']['total'] - total_stats['open'] += tasks_open - total_stats['in_progress'] += tasks_in_progress - total_stats['closed'] += tasks_closed - + total_stats["total"] += stats["tasks"]["total"] + total_stats["open"] += tasks_open + total_stats["in_progress"] += tasks_in_progress + total_stats["closed"] += tasks_closed + # Feedback stats - feedback_open = querysets['feedback'].filter(status='submitted').count() - feedback_in_progress = querysets['feedback'].filter(status='reviewed').count() - feedback_acknowledged = querysets['feedback'].filter(status='acknowledged').count() - feedback_closed = querysets['feedback'].filter(status='closed').count() - stats['feedback'] = { - 'total': querysets['feedback'].count(), - 'open': feedback_open, - 'in_progress': feedback_in_progress, - 'resolved': feedback_acknowledged, - 'closed': feedback_closed, - 'overdue': 0 + feedback_open = querysets["feedback"].filter(status="submitted").count() + feedback_in_progress = querysets["feedback"].filter(status="reviewed").count() + feedback_acknowledged = querysets["feedback"].filter(status="acknowledged").count() + feedback_closed = querysets["feedback"].filter(status="closed").count() + stats["feedback"] = { + "total": querysets["feedback"].count(), + "open": feedback_open, + "in_progress": feedback_in_progress, + "resolved": feedback_acknowledged, + "closed": feedback_closed, + "overdue": 0, } - total_stats['total'] += stats['feedback']['total'] - total_stats['open'] += feedback_open - total_stats['in_progress'] += feedback_in_progress - total_stats['resolved'] += feedback_acknowledged - total_stats['closed'] += feedback_closed - + total_stats["total"] += stats["feedback"]["total"] + total_stats["open"] += feedback_open + total_stats["in_progress"] += feedback_in_progress + total_stats["resolved"] += feedback_acknowledged + total_stats["closed"] += feedback_closed + # Paginate all querysets - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginated_data = {} - + for tab_name, qs in querysets.items(): paginator = Paginator(qs, page_size) - page_number = request.GET.get(f'page_{tab_name}', 1) + page_number = request.GET.get(f"page_{tab_name}", 1) paginated_data[tab_name] = paginator.get_page(page_number) - + # Get chart data chart_data = get_dashboard_chart_data(user, start_date, selected_hospital) - + context = { - 'stats': stats, - 'total_stats': total_stats, - 'paginated_data': paginated_data, - 'active_tab': active_tab, - 'date_range': date_range_days, - 'search_query': search_query, - 'status_filter': status_filter, - 'priority_filter': priority_filter, - 'chart_data': chart_data, - 'selected_hospital': selected_hospital, # For hospital filter display + "stats": stats, + "total_stats": total_stats, + "paginated_data": paginated_data, + "active_tab": active_tab, + "date_range": date_range_days, + "search_query": search_query, + "status_filter": status_filter, + "priority_filter": priority_filter, + "chart_data": chart_data, + "selected_hospital": selected_hospital, # For hospital filter display } - - return render(request, 'dashboard/my_dashboard.html', context) + + return render(request, "dashboard/my_dashboard.html", context) def get_dashboard_chart_data(user, start_date=None, selected_hospital=None): """ Get chart data for dashboard trends. - + Returns JSON-serializable data for ApexCharts. """ from apps.complaints.models import Complaint @@ -837,165 +797,144 @@ def get_dashboard_chart_data(user, start_date=None, selected_hospital=None): from apps.feedback.models import Feedback from apps.complaints.models import Inquiry from apps.projects.models import QIProjectTask - + # Default to last 30 days if no start_date if not start_date: start_date = timezone.now() - timedelta(days=30) - + # Get completion trends completion_data = [] labels = [] - + # Group by day for last 30 days for i in range(30): date = start_date + timedelta(days=i) - date_str = date.strftime('%Y-%m-%d') - labels.append(date.strftime('%b %d')) - + date_str = date.strftime("%Y-%m-%d") + labels.append(date.strftime("%b %d")) + completed_count = 0 # Check each model for completions on this date # Apply hospital filter for PX Admins - complaint_qs = Complaint.objects.filter( - assigned_to=user, - status='closed', - closed_at__date=date.date() - ) + complaint_qs = Complaint.objects.filter(assigned_to=user, status="closed", closed_at__date=date.date()) if selected_hospital: complaint_qs = complaint_qs.filter(hospital=selected_hospital) completed_count += complaint_qs.count() - - inquiry_qs = Inquiry.objects.filter( - assigned_to=user, - status='closed', - updated_at__date=date.date() - ) + + inquiry_qs = Inquiry.objects.filter(assigned_to=user, status="closed", updated_at__date=date.date()) if selected_hospital: inquiry_qs = inquiry_qs.filter(hospital=selected_hospital) completed_count += inquiry_qs.count() - - observation_qs = Observation.objects.filter( - assigned_to=user, - status='closed', - updated_at__date=date.date() - ) + + observation_qs = Observation.objects.filter(assigned_to=user, status="closed", updated_at__date=date.date()) if selected_hospital: observation_qs = observation_qs.filter(hospital=selected_hospital) completed_count += observation_qs.count() - - action_qs = PXAction.objects.filter( - assigned_to=user, - status='closed', - closed_at__date=date.date() - ) + + action_qs = PXAction.objects.filter(assigned_to=user, status="closed", closed_at__date=date.date()) if selected_hospital: action_qs = action_qs.filter(hospital=selected_hospital) completed_count += action_qs.count() - - task_qs = QIProjectTask.objects.filter( - assigned_to=user, - status='closed', - completed_date=date.date() - ) + + task_qs = QIProjectTask.objects.filter(assigned_to=user, status="closed", completed_date=date.date()) if selected_hospital: task_qs = task_qs.filter(project__hospital=selected_hospital) completed_count += task_qs.count() - - feedback_qs = Feedback.objects.filter( - assigned_to=user, - status='closed', - closed_at__date=date.date() - ) + + feedback_qs = Feedback.objects.filter(assigned_to=user, status="closed", closed_at__date=date.date()) if selected_hospital: feedback_qs = feedback_qs.filter(hospital=selected_hospital) completed_count += feedback_qs.count() - + completion_data.append(completed_count) - - return { - 'completion_trend': { - 'labels': labels, - 'data': completion_data - } - } + + return {"completion_trend": {"labels": labels, "data": completion_data}} @login_required def dashboard_bulk_action(request): """ Handle bulk actions on dashboard items. - + Supported actions: - bulk_assign: Assign to user - bulk_status: Change status """ - if request.method != 'POST': - return JsonResponse({'success': False, 'error': 'POST required'}, status=405) - + if request.method != "POST": + return JsonResponse({"success": False, "error": "POST required"}, status=405) + import json + try: data = json.loads(request.body) - action = data.get('action') - tab_name = data.get('tab') - item_ids = data.get('item_ids', []) - + action = data.get("action") + tab_name = data.get("tab") + item_ids = data.get("item_ids", []) + if not action or not tab_name: - return JsonResponse({'success': False, 'error': 'Missing required fields'}, status=400) - + return JsonResponse({"success": False, "error": "Missing required fields"}, status=400) + # Route to appropriate handler based on tab - if tab_name == 'complaints': + if tab_name == "complaints": from apps.complaints.models import Complaint + queryset = Complaint.objects.filter(id__in=item_ids, assigned_to=request.user) - elif tab_name == 'inquiries': + elif tab_name == "inquiries": from apps.complaints.models import Inquiry + queryset = Inquiry.objects.filter(id__in=item_ids, assigned_to=request.user) - elif tab_name == 'observations': + elif tab_name == "observations": from apps.observations.models import Observation + queryset = Observation.objects.filter(id__in=item_ids, assigned_to=request.user) - elif tab_name == 'actions': + elif tab_name == "actions": from apps.px_action_center.models import PXAction + queryset = PXAction.objects.filter(id__in=item_ids, assigned_to=request.user) - elif tab_name == 'tasks': + elif tab_name == "tasks": from apps.projects.models import QIProjectTask + queryset = QIProjectTask.objects.filter(id__in=item_ids, assigned_to=request.user) - elif tab_name == 'feedback': + elif tab_name == "feedback": from apps.feedback.models import Feedback + queryset = Feedback.objects.filter(id__in=item_ids, assigned_to=request.user) else: - return JsonResponse({'success': False, 'error': 'Invalid tab'}, status=400) - + return JsonResponse({"success": False, "error": "Invalid tab"}, status=400) + # Apply bulk action - if action == 'bulk_status': - new_status = data.get('new_status') + if action == "bulk_status": + new_status = data.get("new_status") if not new_status: - return JsonResponse({'success': False, 'error': 'Missing new_status'}, status=400) - + return JsonResponse({"success": False, "error": "Missing new_status"}, status=400) + count = queryset.update(status=new_status) - return JsonResponse({'success': True, 'updated_count': count}) - - elif action == 'bulk_assign': - user_id = data.get('user_id') + return JsonResponse({"success": True, "updated_count": count}) + + elif action == "bulk_assign": + user_id = data.get("user_id") if not user_id: - return JsonResponse({'success': False, 'error': 'Missing user_id'}, status=400) - + return JsonResponse({"success": False, "error": "Missing user_id"}, status=400) + from apps.accounts.models import User + assignee = User.objects.get(id=user_id) count = queryset.update(assigned_to=assignee, assigned_at=timezone.now()) - return JsonResponse({'success': True, 'updated_count': count}) - + return JsonResponse({"success": True, "updated_count": count}) + else: - return JsonResponse({'success': False, 'error': 'Invalid action'}, status=400) - + return JsonResponse({"success": False, "error": "Invalid action"}, status=400) + except json.JSONDecodeError: - return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) + return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400) except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}, status=500) + return JsonResponse({"success": False, "error": str(e)}, status=500) @login_required def admin_evaluation(request): """ Admin Evaluation Dashboard - Staff performance analysis. - + Shows: - Performance metrics for all staff members - Complaints: Source breakdown, status distribution, response time, activation time @@ -1003,57 +942,59 @@ def admin_evaluation(request): - Multi-staff comparison - Date range filtering - Hospital/department filtering - + Access: PX Admin and Hospital Admin only """ from django.core.exceptions import PermissionDenied from apps.analytics.services.analytics_service import UnifiedAnalyticsService from apps.accounts.models import User from apps.organizations.models import Hospital, Department - + user = request.user - + # Only PX Admins and Hospital Admins can access if not (user.is_px_admin() or user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can access the Admin Evaluation dashboard.") - + # Get date range filter - date_range = request.GET.get('date_range', '30d') - custom_start = request.GET.get('custom_start') - custom_end = request.GET.get('custom_end') - + date_range = request.GET.get("date_range", "30d") + custom_start = request.GET.get("custom_start") + custom_end = request.GET.get("custom_end") + # Parse custom dates if provided if custom_start: from datetime import datetime + custom_start = datetime.fromisoformat(custom_start) if custom_end: from datetime import datetime + custom_end = datetime.fromisoformat(custom_end) - + # Get hospital and department filters - hospital_id = request.GET.get('hospital_id') - department_id = request.GET.get('department_id') - + hospital_id = request.GET.get("hospital_id") + department_id = request.GET.get("department_id") + # Get selected staff IDs for comparison - selected_staff_ids = request.GET.getlist('staff_ids') - + selected_staff_ids = request.GET.getlist("staff_ids") + # Get available hospitals (for PX Admins) if user.is_px_admin(): - hospitals = Hospital.objects.filter(status='active') + hospitals = Hospital.objects.filter(status="active") elif user.is_hospital_admin() and user.hospital: hospitals = Hospital.objects.filter(id=user.hospital.id) hospital_id = hospital_id or user.hospital.id # Default to user's hospital else: hospitals = Hospital.objects.none() - + # Get available departments based on hospital filter if hospital_id: - departments = Department.objects.filter(hospital_id=hospital_id, status='active') + departments = Department.objects.filter(hospital_id=hospital_id, status="active") elif user.hospital: - departments = Department.objects.filter(hospital=user.hospital, status='active') + departments = Department.objects.filter(hospital=user.hospital, status="active") else: departments = Department.objects.none() - + # Get staff performance metrics performance_data = UnifiedAnalyticsService.get_staff_performance_metrics( user=user, @@ -1062,77 +1003,81 @@ def admin_evaluation(request): department_id=department_id, staff_ids=selected_staff_ids if selected_staff_ids else None, custom_start=custom_start, - custom_end=custom_end + custom_end=custom_end, ) - + # Get all staff for the dropdown staff_queryset = User.objects.all() - + if user.is_px_admin() and hospital_id: staff_queryset = staff_queryset.filter(hospital_id=hospital_id) elif not user.is_px_admin() and user.hospital: staff_queryset = staff_queryset.filter(hospital=user.hospital) hospital_id = hospital_id or user.hospital.id - + if department_id: staff_queryset = staff_queryset.filter(department_id=department_id) - + # Only staff with assigned complaints or inquiries - staff_queryset = staff_queryset.filter( - Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False) - ).distinct().select_related('hospital', 'department') - + staff_queryset = ( + staff_queryset.filter(Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False)) + .distinct() + .select_related("hospital", "department") + ) + context = { - 'departments': departments, - 'staff_list': staff_queryset, - 'selected_hospital_id': hospital_id, - 'selected_department_id': department_id, - 'selected_staff_ids': selected_staff_ids, - 'date_range': date_range, - 'custom_start': custom_start, - 'custom_end': custom_end, - 'performance_data': performance_data, + "departments": departments, + "staff_list": staff_queryset, + "selected_hospital_id": hospital_id, + "selected_department_id": department_id, + "selected_staff_ids": selected_staff_ids, + "date_range": date_range, + "custom_start": custom_start, + "custom_end": custom_end, + "performance_data": performance_data, } - - return render(request, 'dashboard/admin_evaluation.html', context) + + return render(request, "dashboard/admin_evaluation.html", context) @login_required def admin_evaluation_chart_data(request): """ API endpoint to get chart data for admin evaluation dashboard. - + Access: PX Admin and Hospital Admin only """ from apps.analytics.services.analytics_service import UnifiedAnalyticsService - - if request.method != 'GET': - return JsonResponse({'success': False, 'error': 'GET required'}, status=405) - + + if request.method != "GET": + return JsonResponse({"success": False, "error": "GET required"}, status=405) + user = request.user - + # Only PX Admins and Hospital Admins can access if not (user.is_px_admin() or user.is_hospital_admin()): - return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) - - chart_type = request.GET.get('chart_type') - date_range = request.GET.get('date_range', '30d') - hospital_id = request.GET.get('hospital_id') - department_id = request.GET.get('department_id') - staff_ids = request.GET.getlist('staff_ids') - + return JsonResponse({"success": False, "error": "Permission denied"}, status=403) + + chart_type = request.GET.get("chart_type") + date_range = request.GET.get("date_range", "30d") + hospital_id = request.GET.get("hospital_id") + department_id = request.GET.get("department_id") + staff_ids = request.GET.getlist("staff_ids") + # Parse custom dates if provided - custom_start = request.GET.get('custom_start') - custom_end = request.GET.get('custom_end') + custom_start = request.GET.get("custom_start") + custom_end = request.GET.get("custom_end") if custom_start: from datetime import datetime + custom_start = datetime.fromisoformat(custom_start) if custom_end: from datetime import datetime + custom_end = datetime.fromisoformat(custom_end) - + try: - if chart_type == 'staff_performance': + if chart_type == "staff_performance": data = UnifiedAnalyticsService.get_staff_performance_metrics( user=user, date_range=date_range, @@ -1140,164 +1085,149 @@ def admin_evaluation_chart_data(request): department_id=department_id, staff_ids=staff_ids if staff_ids else None, custom_start=custom_start, - custom_end=custom_end + custom_end=custom_end, ) else: - data = {'error': f'Unknown chart type: {chart_type}'} - - return JsonResponse({'success': True, 'data': data}) - - except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}, status=500) + data = {"error": f"Unknown chart type: {chart_type}"} + return JsonResponse({"success": True, "data": data}) + + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}, status=500) # ============================================================================ # ENHANCED ADMIN EVALUATION VIEWS # ============================================================================ + @login_required def staff_performance_detail(request, staff_id): """ Detailed performance view for a single staff member. - + Shows: - Performance score with breakdown - Daily workload trends - Recent complaints and inquiries - Performance metrics - + Access: PX Admin and Hospital Admin only """ from django.core.exceptions import PermissionDenied from apps.analytics.services.analytics_service import UnifiedAnalyticsService - + # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can access staff performance details.") from apps.accounts.models import User - + user = request.user - + # Get date range - date_range = request.GET.get('date_range', '30d') - + date_range = request.GET.get("date_range", "30d") + try: - staff = User.objects.select_related('hospital', 'department').get(id=staff_id) - + staff = User.objects.select_related("hospital", "department").get(id=staff_id) + # Check permissions if not user.is_px_admin(): if user.hospital and staff.hospital != user.hospital: messages.error(request, "You don't have permission to view this staff member's performance.") - return redirect('dashboard:admin_evaluation') - + return redirect("dashboard:admin_evaluation") + # Get detailed performance performance = UnifiedAnalyticsService.get_staff_detailed_performance( - staff_id=staff_id, - user=user, - date_range=date_range + staff_id=staff_id, user=user, date_range=date_range ) - + # Get trends - trends = UnifiedAnalyticsService.get_staff_performance_trends( - staff_id=staff_id, - user=user, - months=6 - ) - + trends = UnifiedAnalyticsService.get_staff_performance_trends(staff_id=staff_id, user=user, months=6) + context = { - 'staff': performance['staff'], - 'performance': performance, - 'trends': trends, - 'date_range': date_range + "staff": performance["staff"], + "performance": performance, + "trends": trends, + "date_range": date_range, } - - return render(request, 'dashboard/staff_performance_detail.html', context) - + + return render(request, "dashboard/staff_performance_detail.html", context) + except User.DoesNotExist: messages.error(request, "Staff member not found.") - return redirect('dashboard:admin_evaluation') + return redirect("dashboard:admin_evaluation") except PermissionError: messages.error(request, "You don't have permission to view this staff member.") - return redirect('dashboard:admin_evaluation') + return redirect("dashboard:admin_evaluation") @login_required def staff_performance_trends(request, staff_id): """ API endpoint to get staff performance trends as JSON. - + Access: PX Admin and Hospital Admin only """ from apps.analytics.services.analytics_service import UnifiedAnalyticsService from apps.accounts.models import User - + # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): - return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) - + return JsonResponse({"success": False, "error": "Permission denied"}, status=403) + user = request.user - months = int(request.GET.get('months', 6)) - + months = int(request.GET.get("months", 6)) + try: - trends = UnifiedAnalyticsService.get_staff_performance_trends( - staff_id=staff_id, - user=user, - months=months - ) - return JsonResponse({'success': True, 'trends': trends}) + trends = UnifiedAnalyticsService.get_staff_performance_trends(staff_id=staff_id, user=user, months=months) + return JsonResponse({"success": True, "trends": trends}) except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}, status=400) + return JsonResponse({"success": False, "error": str(e)}, status=400) @login_required def department_benchmarks(request): """ Department benchmarking view comparing all staff. - + Access: PX Admin and Hospital Admin only """ from django.core.exceptions import PermissionDenied from apps.analytics.services.analytics_service import UnifiedAnalyticsService - + # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can access department benchmarks.") - + user = request.user - + # Get filters - department_id = request.GET.get('department_id') - date_range = request.GET.get('date_range', '30d') - + department_id = request.GET.get("department_id") + date_range = request.GET.get("date_range", "30d") + # If user is department manager, use their department if user.is_department_manager() and user.department and not department_id: department_id = str(user.department.id) - + try: benchmarks = UnifiedAnalyticsService.get_department_benchmarks( - user=user, - department_id=department_id, - date_range=date_range + user=user, department_id=department_id, date_range=date_range ) - - context = { - 'benchmarks': benchmarks, - 'date_range': date_range - } - - return render(request, 'dashboard/department_benchmarks.html', context) - + + context = {"benchmarks": benchmarks, "date_range": date_range} + + return render(request, "dashboard/department_benchmarks.html", context) + except Exception as e: messages.error(request, f"Error loading benchmarks: {str(e)}") - return redirect('dashboard:admin_evaluation') + return redirect("dashboard:admin_evaluation") @login_required def export_staff_performance(request): """ Export staff performance report in various formats. - + Access: PX Admin and Hospital Admin only """ from django.core.exceptions import PermissionDenied @@ -1305,56 +1235,55 @@ def export_staff_performance(request): import csv import json from django.http import HttpResponse - + # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can export staff performance.") - + user = request.user - - if request.method != 'POST': - return JsonResponse({'error': 'POST required'}, status=405) - + + if request.method != "POST": + return JsonResponse({"error": "POST required"}, status=405) + try: data = json.loads(request.body) - staff_ids = data.get('staff_ids', []) - date_range = data.get('date_range', '30d') - format_type = data.get('format', 'csv') - + staff_ids = data.get("staff_ids", []) + date_range = data.get("date_range", "30d") + format_type = data.get("format", "csv") + # Generate report report = UnifiedAnalyticsService.export_staff_performance_report( - staff_ids=staff_ids, - user=user, - date_range=date_range, - format_type=format_type + staff_ids=staff_ids, user=user, date_range=date_range, format_type=format_type ) - - if format_type == 'csv': - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = f'attachment; filename="staff_performance_{timezone.now().strftime("%Y%m%d")}.csv"' - - if report['data']: - writer = csv.DictWriter(response, fieldnames=report['data'][0].keys()) + + if format_type == "csv": + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = ( + f'attachment; filename="staff_performance_{timezone.now().strftime("%Y%m%d")}.csv"' + ) + + if report["data"]: + writer = csv.DictWriter(response, fieldnames=report["data"][0].keys()) writer.writeheader() - writer.writerows(report['data']) - + writer.writerows(report["data"]) + return response - - elif format_type == 'json': + + elif format_type == "json": return JsonResponse(report) - + else: - return JsonResponse({'error': f'Unsupported format: {format_type}'}, status=400) - + return JsonResponse({"error": f"Unsupported format: {format_type}"}, status=400) + except Exception as e: - return JsonResponse({'error': str(e)}, status=500) + return JsonResponse({"error": str(e)}, status=500) @login_required def command_center_api(request): """ API endpoint for Command Center live data updates. - + Returns JSON with all module data for AJAX refresh without page reload. Enables true real-time updates every 30-60 seconds. """ @@ -1363,13 +1292,13 @@ def command_center_api(request): from apps.surveys.models import SurveyInstance from apps.physicians.models import PhysicianMonthlyRating from apps.observations.models import Observation - + user = request.user now = timezone.now() last_24h = now - timedelta(hours=24) last_30d = now - timedelta(days=30) last_60d = now - timedelta(days=60) - + # Build querysets based on user role if user.is_px_admin(): hospital = request.tenant_hospital @@ -1396,139 +1325,409 @@ def command_center_api(request): actions_qs = PXAction.objects.none() surveys_qs = SurveyInstance.objects.none() observations_qs = Observation.objects.none() - + # Calculate all module data # Complaints complaints_current = complaints_qs.filter(created_at__gte=last_30d).count() complaints_previous = complaints_qs.filter(created_at__gte=last_60d, created_at__lt=last_30d).count() - complaints_variance = round(((complaints_current - complaints_previous) / complaints_previous) * 100, 1) if complaints_previous > 0 else 0 - + complaints_variance = ( + round(((complaints_current - complaints_previous) / complaints_previous) * 100, 1) + if complaints_previous > 0 + else 0 + ) + # Surveys surveys_completed_30d = surveys_qs.filter(completed_at__gte=last_30d) total_surveys_30d = surveys_completed_30d.count() positive_count = surveys_completed_30d.filter(is_negative=False).count() negative_count = surveys_completed_30d.filter(is_negative=True).count() nps_score = round(((positive_count - negative_count) / total_surveys_30d) * 100) if total_surveys_30d > 0 else 0 - avg_satisfaction = surveys_completed_30d.filter(total_score__isnull=False).aggregate(Avg('total_score'))['total_score__avg'] or 0 + avg_satisfaction = ( + surveys_completed_30d.filter(total_score__isnull=False).aggregate(Avg("total_score"))["total_score__avg"] or 0 + ) surveys_sent_30d = surveys_qs.filter(sent_at__gte=last_30d).count() response_rate = round((total_surveys_30d / surveys_sent_30d) * 100, 1) if surveys_sent_30d > 0 else 0 - + # Actions - actions_open = actions_qs.filter(status='open').count() - actions_in_progress = actions_qs.filter(status='in_progress').count() - actions_pending_approval = actions_qs.filter(status='pending_approval').count() - actions_closed_30d = actions_qs.filter(status='closed', closed_at__gte=last_30d).count() - + actions_open = actions_qs.filter(status="open").count() + actions_in_progress = actions_qs.filter(status="in_progress").count() + actions_pending_approval = actions_qs.filter(status="pending_approval").count() + actions_closed_30d = actions_qs.filter(status="closed", closed_at__gte=last_30d).count() + # Red alerts red_alerts = [] - critical_complaints = complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count() + critical_complaints = complaints_qs.filter(severity="critical", status__in=["open", "in_progress"]).count() if critical_complaints > 0: - red_alerts.append({'type': 'critical_complaints', 'value': critical_complaints}) - overdue_complaints = complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count() + red_alerts.append({"type": "critical_complaints", "value": critical_complaints}) + overdue_complaints = complaints_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count() if overdue_complaints > 0: - red_alerts.append({'type': 'overdue_complaints', 'value': overdue_complaints}) - escalated_actions = actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count() + red_alerts.append({"type": "overdue_complaints", "value": overdue_complaints}) + escalated_actions = actions_qs.filter(escalation_level__gt=0, status__in=["open", "in_progress"]).count() if escalated_actions > 0: - red_alerts.append({'type': 'escalated_actions', 'value': escalated_actions}) + red_alerts.append({"type": "escalated_actions", "value": escalated_actions}) negative_surveys_24h = surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count() if negative_surveys_24h > 0: - red_alerts.append({'type': 'negative_surveys', 'value': negative_surveys_24h}) - - return JsonResponse({ - 'success': True, - 'timestamp': now.isoformat(), - 'last_updated': now.strftime('%Y-%m-%d %H:%M:%S'), - 'red_alerts': { - 'has_alerts': len(red_alerts) > 0, - 'count': len(red_alerts), - 'items': red_alerts - }, - 'modules': { - 'complaints': { - 'total_active': complaints_qs.filter(status__in=['open', 'in_progress']).count(), - 'variance': complaints_variance, - 'variance_direction': 'up' if complaints_variance > 0 else 'down' if complaints_variance < 0 else 'neutral', - 'overdue': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(), - 'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(), - 'by_severity': { - 'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(), - 'high': complaints_qs.filter(severity='high', status__in=['open', 'in_progress']).count(), - 'medium': complaints_qs.filter(severity='medium', status__in=['open', 'in_progress']).count(), - 'low': complaints_qs.filter(severity='low', status__in=['open', 'in_progress']).count(), - } + red_alerts.append({"type": "negative_surveys", "value": negative_surveys_24h}) + + return JsonResponse( + { + "success": True, + "timestamp": now.isoformat(), + "last_updated": now.strftime("%Y-%m-%d %H:%M:%S"), + "red_alerts": {"has_alerts": len(red_alerts) > 0, "count": len(red_alerts), "items": red_alerts}, + "modules": { + "complaints": { + "total_active": complaints_qs.filter(status__in=["open", "in_progress"]).count(), + "variance": complaints_variance, + "variance_direction": "up" + if complaints_variance > 0 + else "down" + if complaints_variance < 0 + else "neutral", + "overdue": complaints_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count(), + "critical": complaints_qs.filter(severity="critical", status__in=["open", "in_progress"]).count(), + "by_severity": { + "critical": complaints_qs.filter( + severity="critical", status__in=["open", "in_progress"] + ).count(), + "high": complaints_qs.filter(severity="high", status__in=["open", "in_progress"]).count(), + "medium": complaints_qs.filter(severity="medium", status__in=["open", "in_progress"]).count(), + "low": complaints_qs.filter(severity="low", status__in=["open", "in_progress"]).count(), + }, + }, + "surveys": { + "nps_score": nps_score, + "avg_satisfaction": round(avg_satisfaction, 1), + "response_rate": response_rate, + "total_completed": total_surveys_30d, + "negative_24h": surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(), + }, + "actions": { + "open": actions_open, + "in_progress": actions_in_progress, + "pending_approval": actions_pending_approval, + "closed_30d": actions_closed_30d, + "overdue": actions_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count(), + "escalated": actions_qs.filter(escalation_level__gt=0, status__in=["open", "in_progress"]).count(), + }, + "inquiries": { + "open": inquiries_qs.filter(status="open").count(), + "in_progress": inquiries_qs.filter(status="in_progress").count(), + "total_active": inquiries_qs.filter(status__in=["open", "in_progress"]).count(), + "new_24h": inquiries_qs.filter(created_at__gte=last_24h).count(), + }, + "observations": { + "new": observations_qs.filter(status="new").count(), + "in_progress": observations_qs.filter(status="in_progress").count(), + "total_active": observations_qs.filter(status__in=["new", "in_progress"]).count(), + "critical": observations_qs.filter( + severity="critical", status__in=["new", "triaged", "assigned", "in_progress"] + ).count(), + }, }, - 'surveys': { - 'nps_score': nps_score, - 'avg_satisfaction': round(avg_satisfaction, 1), - 'response_rate': response_rate, - 'total_completed': total_surveys_30d, - 'negative_24h': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count() - }, - 'actions': { - 'open': actions_open, - 'in_progress': actions_in_progress, - 'pending_approval': actions_pending_approval, - 'closed_30d': actions_closed_30d, - 'overdue': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(), - 'escalated': actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count() - }, - 'inquiries': { - 'open': inquiries_qs.filter(status='open').count(), - 'in_progress': inquiries_qs.filter(status='in_progress').count(), - 'total_active': inquiries_qs.filter(status__in=['open', 'in_progress']).count(), - 'new_24h': inquiries_qs.filter(created_at__gte=last_24h).count() - }, - 'observations': { - 'new': observations_qs.filter(status='new').count(), - 'in_progress': observations_qs.filter(status='in_progress').count(), - 'total_active': observations_qs.filter(status__in=['new', 'in_progress']).count(), - 'critical': observations_qs.filter(severity='critical', status__in=['new', 'triaged', 'assigned', 'in_progress']).count() - } } - }) + ) @login_required def performance_analytics_api(request): """ API endpoint for various performance analytics. - + Access: PX Admin and Hospital Admin only """ from apps.analytics.services.analytics_service import UnifiedAnalyticsService - + # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): - return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) - + return JsonResponse({"success": False, "error": "Permission denied"}, status=403) + user = request.user - chart_type = request.GET.get('chart_type') - + chart_type = request.GET.get("chart_type") + try: - if chart_type == 'staff_trends': - staff_id = request.GET.get('staff_id') - months = int(request.GET.get('months', 6)) - - data = UnifiedAnalyticsService.get_staff_performance_trends( - staff_id=staff_id, - user=user, - months=months - ) - - elif chart_type == 'department_benchmarks': - department_id = request.GET.get('department_id') - date_range = request.GET.get('date_range', '30d') - + if chart_type == "staff_trends": + staff_id = request.GET.get("staff_id") + months = int(request.GET.get("months", 6)) + + data = UnifiedAnalyticsService.get_staff_performance_trends(staff_id=staff_id, user=user, months=months) + + elif chart_type == "department_benchmarks": + department_id = request.GET.get("department_id") + date_range = request.GET.get("date_range", "30d") + data = UnifiedAnalyticsService.get_department_benchmarks( - user=user, - department_id=department_id, - date_range=date_range + user=user, department_id=department_id, date_range=date_range ) - + else: - return JsonResponse({'error': f'Unknown chart type: {chart_type}'}, status=400) - - return JsonResponse({'success': True, 'data': data}) - + return JsonResponse({"error": f"Unknown chart type: {chart_type}"}, status=400) + + return JsonResponse({"success": True, "data": data}) + except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}, status=500) + return JsonResponse({"success": False, "error": str(e)}, status=500) + + +@login_required +def employee_evaluation(request): + """ + Employee Evaluation Dashboard - PAD Department Weekly Dashboard. + + Shows comprehensive performance metrics for all staff members side-by-side. + Based on the employee_evaluation.md specification. + + Access: PX Admin and Hospital Admin only + """ + from django.core.exceptions import PermissionDenied + from apps.analytics.services.analytics_service import UnifiedAnalyticsService + from apps.accounts.models import User + from apps.organizations.models import Hospital, Department + + user = request.user + + # Only PX Admins and Hospital Admins can access + if not (user.is_px_admin() or user.is_hospital_admin()): + raise PermissionDenied("Only PX Admins and Hospital Admins can access the Employee Evaluation dashboard.") + + # Get date range filter - default to last 7 days (weekly) + date_range = request.GET.get("date_range", "7d") + custom_start = request.GET.get("custom_start") + custom_end = request.GET.get("custom_end") + + # Parse custom dates if provided + if custom_start: + from datetime import datetime + + custom_start = datetime.fromisoformat(custom_start) + if custom_end: + from datetime import datetime + + custom_end = datetime.fromisoformat(custom_end) + + # Get hospital and department filters + hospital_id = request.GET.get("hospital_id") + department_id = request.GET.get("department_id") + + # Get selected staff IDs for comparison + selected_staff_ids = request.GET.getlist("staff_ids") + + # Get available hospitals (for PX Admins) + if user.is_px_admin(): + hospitals = Hospital.objects.filter(status="active") + elif user.is_hospital_admin() and user.hospital: + hospitals = Hospital.objects.filter(id=user.hospital.id) + hospital_id = hospital_id or user.hospital.id # Default to user's hospital + else: + hospitals = Hospital.objects.none() + + # Get available departments based on hospital filter + if hospital_id: + departments = Department.objects.filter(hospital_id=hospital_id, status="active") + elif user.hospital: + departments = Department.objects.filter(hospital=user.hospital, status="active") + else: + departments = Department.objects.none() + + # Get employee evaluation metrics + evaluation_data = UnifiedAnalyticsService.get_employee_evaluation_metrics( + user=user, + date_range=date_range, + hospital_id=hospital_id, + department_id=department_id, + staff_ids=selected_staff_ids if selected_staff_ids else None, + custom_start=custom_start, + custom_end=custom_end, + ) + + # Get all staff for the dropdown + staff_queryset = User.objects.all() + + if user.is_px_admin() and hospital_id: + staff_queryset = staff_queryset.filter(hospital_id=hospital_id) + elif not user.is_px_admin() and user.hospital: + staff_queryset = staff_queryset.filter(hospital=user.hospital) + hospital_id = hospital_id or user.hospital.id + + if department_id: + staff_queryset = staff_queryset.filter(department_id=department_id) + + # Only staff with assigned complaints or inquiries + staff_queryset = ( + staff_queryset.filter(Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False)) + .distinct() + .select_related("hospital", "department") + ) + + context = { + "departments": departments, + "staff_list": staff_queryset, + "selected_hospital_id": hospital_id, + "selected_department_id": department_id, + "selected_staff_ids": selected_staff_ids, + "date_range": date_range, + "custom_start": custom_start, + "custom_end": custom_end, + "evaluation_data": evaluation_data, + } + + return render(request, "dashboard/employee_evaluation.html", context) + + +@login_required +def employee_evaluation_data(request): + """ + API endpoint to get employee evaluation data for charts and tables. + + Access: PX Admin and Hospital Admin only + """ + from apps.analytics.services.analytics_service import UnifiedAnalyticsService + + if request.method != "GET": + return JsonResponse({"success": False, "error": "GET required"}, status=405) + + user = request.user + + # Only PX Admins and Hospital Admins can access + if not (user.is_px_admin() or user.is_hospital_admin()): + return JsonResponse({"success": False, "error": "Permission denied"}, status=403) + + date_range = request.GET.get("date_range", "7d") + hospital_id = request.GET.get("hospital_id") + department_id = request.GET.get("department_id") + staff_ids = request.GET.getlist("staff_ids") + + # Parse custom dates if provided + custom_start = request.GET.get("custom_start") + custom_end = request.GET.get("custom_end") + if custom_start: + from datetime import datetime + + custom_start = datetime.fromisoformat(custom_start) + if custom_end: + from datetime import datetime + + custom_end = datetime.fromisoformat(custom_end) + + try: + # Get employee evaluation metrics + evaluation_data = UnifiedAnalyticsService.get_employee_evaluation_metrics( + user=user, + date_range=date_range, + hospital_id=hospital_id, + department_id=department_id, + staff_ids=staff_ids if staff_ids else None, + custom_start=custom_start, + custom_end=custom_end, + ) + + return JsonResponse({"success": True, "data": evaluation_data}) + + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}, status=500) + return JsonResponse({"success": False, "error": str(e)}, status=500) + + +@login_required +def complaint_request_list(request): + """ + Step 0 — Complaint Request List view. + + Shows all complaint requests (filled, not filled, on hold) + with filters for month, staff, status. Includes export button. + """ + from django.core.exceptions import PermissionDenied + from apps.dashboard.models import ComplaintRequest + + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + raise PermissionDenied("Only PX Admins and Hospital Admins can access.") + + qs = ComplaintRequest.objects.select_related("staff", "hospital", "complained_department", "complaint") + + if user.is_hospital_admin() and user.hospital: + qs = qs.filter(hospital=user.hospital) + elif user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(hospital=request.tenant_hospital) + + month = request.GET.get("month") + year = request.GET.get("year") + staff_id = request.GET.get("staff_id") + status = request.GET.get("status") + + if year and month: + qs = qs.filter(request_date__year=int(year), request_date__month=int(month)) + elif year: + qs = qs.filter(request_date__year=int(year)) + + if staff_id: + qs = qs.filter(staff_id=staff_id) + + if status == "filled": + qs = qs.filter(filled=True) + elif status == "not_filled": + qs = qs.filter(not_filled=True) + elif status == "on_hold": + qs = qs.filter(on_hold=True) + elif status == "barcode": + qs = qs.filter(from_barcode=True) + + qs = qs.order_by("-request_date", "-created_at") + + paginator = Paginator(qs, 50) + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + + from apps.accounts.models import User + + staff_members = ( + User.objects.filter(complaint_requests_sent__isnull=False).distinct().order_by("first_name", "last_name") + ) + + context = { + "page_obj": page_obj, + "staff_members": staff_members, + "selected_year": year, + "selected_month": month, + "selected_staff": staff_id, + "selected_status": status, + "years_range": range(2024, timezone.now().year + 1), + "months_range": range(1, 13), + } + + return render(request, "dashboard/complaint_request_list.html", context) + + +@login_required +def complaint_request_export(request): + """ + Step 0 — Export complaint requests to Excel. + """ + from django.core.exceptions import PermissionDenied + from apps.dashboard.models import ComplaintRequest + from apps.complaints.utils import export_requests_report + + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + raise PermissionDenied("Only PX Admins and Hospital Admins can export.") + + qs = ComplaintRequest.objects.select_related("staff", "hospital", "complained_department", "complaint") + + if user.is_hospital_admin() and user.hospital: + qs = qs.filter(hospital=user.hospital) + elif user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(hospital=request.tenant_hospital) + + year = request.GET.get("year") + month = request.GET.get("month") + + if year and month: + qs = qs.filter(request_date__year=int(year), request_date__month=int(month)) + elif year: + qs = qs.filter(request_date__year=int(year)) + + return export_requests_report( + qs, + year=int(year) if year else None, + month=int(month) if month else None, + ) diff --git a/apps/feedback/admin.py b/apps/feedback/admin.py index 2dc5998..93546ea 100644 --- a/apps/feedback/admin.py +++ b/apps/feedback/admin.py @@ -1,109 +1,195 @@ """ Feedback admin configuration """ + from django.contrib import admin -from .models import Feedback, FeedbackAttachment, FeedbackResponse +from .models import ( + Feedback, + FeedbackAttachment, + FeedbackResponse, + CommentImport, + PatientComment, + CommentActionPlan, +) @admin.register(Feedback) class FeedbackAdmin(admin.ModelAdmin): """Admin interface for Feedback model""" + list_display = [ - 'id', 'feedback_type', 'title', 'get_contact_name', 'hospital', - 'status', 'sentiment', 'rating', 'is_featured', 'created_at' + "id", + "feedback_type", + "title", + "get_contact_name", + "hospital", + "status", + "sentiment", + "rating", + "is_featured", + "created_at", ] list_filter = [ - 'feedback_type', 'status', 'sentiment', 'category', - 'priority', 'is_featured', 'is_deleted', 'created_at' + "feedback_type", + "status", + "sentiment", + "category", + "priority", + "is_featured", + "is_deleted", + "created_at", ] search_fields = [ - 'title', 'message', 'patient__first_name', 'patient__last_name', - 'patient__mrn', 'contact_name', 'contact_email' + "title", + "message", + "patient__first_name", + "patient__last_name", + "patient__mrn", + "contact_name", + "contact_email", ] readonly_fields = [ - 'id', 'created_at', 'updated_at', 'assigned_at', 'reviewed_at', - 'acknowledged_at', 'closed_at', 'deleted_at' + "id", + "created_at", + "updated_at", + "assigned_at", + "reviewed_at", + "acknowledged_at", + "closed_at", + "deleted_at", ] fieldsets = ( - ('Basic Information', { - 'fields': ( - 'id', 'feedback_type', 'title', 'message', 'category', - 'subcategory', 'rating', 'priority' - ) - }), - ('Patient/Contact', { - 'fields': ( - 'patient', 'is_anonymous', 'contact_name', 'contact_email', - 'contact_phone' - ) - }), - ('Organization', { - 'fields': ('hospital', 'department', 'physician', 'encounter_id') - }), - ('Status & Workflow', { - 'fields': ( - 'status', 'assigned_to', 'assigned_at', 'reviewed_by', - 'reviewed_at', 'acknowledged_by', 'acknowledged_at', - 'closed_by', 'closed_at' - ) - }), - ('Sentiment Analysis', { - 'fields': ('sentiment', 'sentiment_score') - }), - ('Flags', { - 'fields': ( - 'is_featured', 'is_public', 'requires_follow_up', - 'is_deleted', 'deleted_at', 'deleted_by' - ) - }), - ('Metadata', { - 'fields': ('source', 'metadata', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ( + "Basic Information", + {"fields": ("id", "feedback_type", "title", "message", "category", "subcategory", "rating", "priority")}, + ), + ("Patient/Contact", {"fields": ("patient", "is_anonymous", "contact_name", "contact_email", "contact_phone")}), + ("Organization", {"fields": ("hospital", "department", "physician", "encounter_id")}), + ( + "Status & Workflow", + { + "fields": ( + "status", + "assigned_to", + "assigned_at", + "reviewed_by", + "reviewed_at", + "acknowledged_by", + "acknowledged_at", + "closed_by", + "closed_at", + ) + }, + ), + ("Sentiment Analysis", {"fields": ("sentiment", "sentiment_score")}), + ( + "Flags", + {"fields": ("is_featured", "is_public", "requires_follow_up", "is_deleted", "deleted_at", "deleted_by")}, + ), + ("Metadata", {"fields": ("source", "metadata", "created_at", "updated_at"), "classes": ("collapse",)}), ) - date_hierarchy = 'created_at' - ordering = ['-created_at'] + date_hierarchy = "created_at" + ordering = ["-created_at"] @admin.register(FeedbackAttachment) class FeedbackAttachmentAdmin(admin.ModelAdmin): """Admin interface for FeedbackAttachment model""" + + list_display = ["id", "feedback", "filename", "file_type", "file_size", "uploaded_by", "created_at"] + list_filter = ["file_type", "created_at"] + search_fields = ["filename", "feedback__title"] + readonly_fields = ["id", "created_at", "updated_at"] + date_hierarchy = "created_at" + ordering = ["-created_at"] + + +@admin.register(CommentImport) +class CommentImportAdmin(admin.ModelAdmin): + list_display = ["hospital", "year", "month", "status", "total_rows", "imported_count", "imported_by", "created_at"] + list_filter = ["status", "year", "month", "hospital"] + date_hierarchy = "created_at" + raw_id_fields = ["hospital", "imported_by"] + + +@admin.register(PatientComment) +class PatientCommentAdmin(admin.ModelAdmin): list_display = [ - 'id', 'feedback', 'filename', 'file_type', 'file_size', - 'uploaded_by', 'created_at' + "serial_number", + "hospital", + "source_category", + "classification", + "sub_category", + "sentiment", + "is_classified", + "year", + "month", ] - list_filter = ['file_type', 'created_at'] - search_fields = ['filename', 'feedback__title'] - readonly_fields = ['id', 'created_at', 'updated_at'] - date_hierarchy = 'created_at' - ordering = ['-created_at'] + list_filter = [ + "hospital", + "source_category", + "classification", + "sub_category", + "sentiment", + "is_classified", + "year", + "month", + ] + search_fields = ["comment_text", "comment_text_en", "negative_keywords", "positive_keywords"] + date_hierarchy = "created_at" + raw_id_fields = ["hospital", "comment_import"] + fieldsets = ( + (None, {"fields": ("hospital", "comment_import", "serial_number", "source_category", "year", "month")}), + ("Comment Text", {"fields": ("comment_text", "comment_text_en")}), + ("Classification (Step 1)", {"fields": ("classification", "sub_category", "is_classified", "sentiment")}), + ( + "Sentiment Keywords", + {"fields": ("negative_keywords", "positive_keywords", "gratitude_keywords", "suggestions")}, + ), + ("Doctor Reference", {"fields": ("mentioned_doctor_name", "mentioned_doctor_name_en", "frequency")}), + ("Metadata", {"fields": ("metadata",), "classes": ("collapse",)}), + ) + + +@admin.register(CommentActionPlan) +class CommentActionPlanAdmin(admin.ModelAdmin): + list_display = [ + "department_label", + "problem_number", + "status", + "frequency", + "timeframe", + "year", + "month", + ] + list_filter = ["status", "hospital", "department", "year", "month"] + search_fields = ["recommendation", "comment_text", "department_label", "responsible_department"] + date_hierarchy = "created_at" + raw_id_fields = ["hospital", "department", "comment"] + fieldsets = ( + (None, {"fields": ("hospital", "department", "department_label", "problem_number", "year", "month")}), + ("Source Comment", {"fields": ("comment", "comment_text", "comment_text_en", "frequency")}), + ("Action Plan", {"fields": ("recommendation", "recommendation_en", "responsible_department")}), + ("Tracking (Step 5)", {"fields": ("status", "timeframe", "evidences")}), + ) @admin.register(FeedbackResponse) class FeedbackResponseAdmin(admin.ModelAdmin): """Admin interface for FeedbackResponse model""" - list_display = [ - 'id', 'feedback', 'response_type', 'created_by', - 'is_internal', 'created_at' - ] - list_filter = ['response_type', 'is_internal', 'created_at'] - search_fields = ['message', 'feedback__title'] - readonly_fields = ['id', 'created_at', 'updated_at'] + + list_display = ["id", "feedback", "response_type", "created_by", "is_internal", "created_at"] + list_filter = ["response_type", "is_internal", "created_at"] + search_fields = ["message", "feedback__title"] + readonly_fields = ["id", "created_at", "updated_at"] fieldsets = ( - ('Response Information', { - 'fields': ( - 'id', 'feedback', 'response_type', 'message', - 'created_by', 'is_internal' - ) - }), - ('Status Change', { - 'fields': ('old_status', 'new_status') - }), - ('Metadata', { - 'fields': ('metadata', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ( + "Response Information", + {"fields": ("id", "feedback", "response_type", "message", "created_by", "is_internal")}, + ), + ("Status Change", {"fields": ("old_status", "new_status")}), + ("Metadata", {"fields": ("metadata", "created_at", "updated_at"), "classes": ("collapse",)}), ) - date_hierarchy = 'created_at' - ordering = ['-created_at'] + date_hierarchy = "created_at" + ordering = ["-created_at"] diff --git a/apps/feedback/export_utils.py b/apps/feedback/export_utils.py new file mode 100644 index 0000000..286bd14 --- /dev/null +++ b/apps/feedback/export_utils.py @@ -0,0 +1,255 @@ +""" +Comments workflow export utilities. + +Generates Excel exports for Steps 1, 2, 3, and 5 of the comments workflow. +""" + +from collections import defaultdict + +from django.http import HttpResponse +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +from .models import ( + CommentClassification, + CommentSubCategory, + COMMENT_SUB_CATEGORY_MAP, +) + + +HEADER_FONT = Font(bold=True, color="FFFFFF", size=11) +HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") +HEADER_ALIGNMENT = Alignment(horizontal="center", vertical="center", wrap_text=True) +SECTION_FONT = Font(bold=True, size=12) +SECTION_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid") +SUBSECTION_FONT = Font(bold=True, size=11, italic=True) +THIN_BORDER = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), +) + + +def _write_header(ws, row, headers): + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=row, column=col_num, value=header) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.alignment = HEADER_ALIGNMENT + cell.border = THIN_BORDER + + +def _write_row(ws, row, values, border=True): + for col_num, val in enumerate(values, 1): + cell = ws.cell(row=row, column=col_num, value=val) + if border: + cell.border = THIN_BORDER + + +def export_classified_comments(queryset): + """ + Step 1 — Classification export. + + Exports all classified comments with their categories, + sub-categories, and sentiment keywords. + """ + wb = Workbook() + ws = wb.active + ws.title = "Classification" + + _write_header( + ws, + 1, + [ + "SN", + "Source Category", + "Comment", + "Classification", + "Sub-Category", + "Negative", + "Positive", + "Gratitude", + "Suggestions", + ], + ) + + row = 2 + for idx, c in enumerate(queryset, 1): + _write_row( + ws, + row, + [ + c.serial_number or idx, + c.get_source_category_display(), + c.comment_text, + c.get_classification_display(), + c.get_sub_category_display(), + c.negative_keywords, + c.positive_keywords, + c.gratitude_keywords, + c.suggestions, + ], + ) + row += 1 + + ws.column_dimensions["A"].width = 8 + ws.column_dimensions["B"].width = 15 + ws.column_dimensions["C"].width = 60 + ws.column_dimensions["D"].width = 18 + ws.column_dimensions["E"].width = 22 + for col_letter in ["F", "G", "H", "I"]: + ws.column_dimensions[col_letter].width = 30 + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = 'attachment; filename="comments_classification.xlsx"' + wb.save(response) + return response + + +def export_filtered_comments_by_dept(queryset): + """ + Step 2 — Filtering by department. + + Creates one sheet per department category/sub-category, + grouping comments with translations and negative summaries. + """ + wb = Workbook() + + dept_comments = defaultdict(list) + for c in queryset: + dept_key = f"{c.get_classification_display()} / {c.get_sub_category_display()}" + dept_comments[dept_key].append(c) + + first_sheet = True + for dept_key, comments in dept_comments.items(): + classification, _, sub_category = dept_key.partition(" / ") + + if first_sheet: + ws = wb.active + ws.title = dept_key[:31] + first_sheet = False + else: + ws = wb.create_sheet(title=dept_key[:31]) + + ws.cell(row=1, column=1, value=classification).font = SECTION_FONT + ws.cell(row=2, column=1, value=sub_category).font = SUBSECTION_FONT + + _write_header(ws, 4, ["#", "Comment (Arabic)", "Comment (English)", "Negative", "Sentiment"]) + row = 5 + for idx, c in enumerate(comments, 1): + _write_row( + ws, + row, + [ + idx, + c.comment_text, + c.comment_text_en, + c.negative_keywords, + c.get_sentiment_display(), + ], + ) + row += 1 + + ws.column_dimensions["A"].width = 6 + ws.column_dimensions["B"].width = 50 + ws.column_dimensions["C"].width = 50 + ws.column_dimensions["D"].width = 40 + ws.column_dimensions["E"].width = 12 + + if first_sheet: + ws = wb.active + ws.cell(row=1, column=1, value="No comments found for the selected filters.") + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = 'attachment; filename="comments_filtered_by_dept.xlsx"' + wb.save(response) + return response + + +def export_action_plans(queryset): + """ + Step 3/5 — Action plan export with tracking status. + + Groups action plans by department with problem numbers, + recommendations, responsible departments, and status. + """ + wb = Workbook() + ws = wb.active + ws.title = "Action Plans" + + _write_header( + ws, + 1, + [ + "Problem #", + "Comment", + "English Translation", + "Frequency", + "Recommendation / Action Plan", + "Responsible Department", + "Timeframe", + "Status", + "Evidences", + ], + ) + + dept_plans = defaultdict(list) + for plan in queryset: + dept_label = plan.department_label or (plan.department.name if plan.department else "Unassigned") + dept_plans[dept_label].append(plan) + + row = 2 + current_dept = None + for dept_label, plans in dept_plans.items(): + if dept_label != current_dept: + ws.cell(row=row, column=1, value=dept_label).font = SECTION_FONT + row += 1 + current_dept = dept_label + + for plan in plans: + _write_row( + ws, + row, + [ + plan.problem_number or "", + plan.comment_text, + plan.comment_text_en, + plan.frequency, + plan.recommendation, + plan.responsible_department or (plan.department.name if plan.department else ""), + plan.timeframe, + plan.get_status_display(), + plan.evidences, + ], + ) + row += 1 + + row += 2 + ws.cell(row=row, column=1, value="Summary").font = SECTION_FONT + row += 1 + _write_header(ws, row, ["Department", "Total Plans", "Completed", "On Process", "Pending"]) + row += 1 + + for dept_label, plans in dept_plans.items(): + completed = sum(1 for p in plans if p.status == "completed") + on_process = sum(1 for p in plans if p.status == "on_process") + pending = sum(1 for p in plans if p.status == "pending") + _write_row(ws, row, [dept_label, len(plans), completed, on_process, pending]) + row += 1 + + ws.column_dimensions["A"].width = 20 + ws.column_dimensions["B"].width = 50 + ws.column_dimensions["C"].width = 50 + ws.column_dimensions["D"].width = 10 + ws.column_dimensions["E"].width = 40 + ws.column_dimensions["F"].width = 25 + ws.column_dimensions["G"].width = 15 + ws.column_dimensions["H"].width = 12 + ws.column_dimensions["I"].width = 30 + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = 'attachment; filename="comments_action_plans.xlsx"' + wb.save(response) + return response diff --git a/apps/feedback/models.py b/apps/feedback/models.py index e7710d7..ca3383f 100644 --- a/apps/feedback/models.py +++ b/apps/feedback/models.py @@ -7,6 +7,7 @@ This module implements the feedback management system that: - Maintains feedback responses and timeline - Supports attachments and ratings """ + from django.conf import settings from django.db import models from django.utils import timezone @@ -16,40 +17,44 @@ from apps.core.models import PriorityChoices, TimeStampedModel, UUIDModel class FeedbackType(models.TextChoices): """Feedback type choices""" - COMPLIMENT = 'compliment', 'Compliment' - SUGGESTION = 'suggestion', 'Suggestion' - GENERAL = 'general', 'General Feedback' - INQUIRY = 'inquiry', 'Inquiry' - SATISFACTION_CHECK = 'satisfaction_check', 'Satisfaction Check' + + COMPLIMENT = "compliment", "Compliment" + SUGGESTION = "suggestion", "Suggestion" + GENERAL = "general", "General Feedback" + INQUIRY = "inquiry", "Inquiry" + SATISFACTION_CHECK = "satisfaction_check", "Satisfaction Check" class FeedbackStatus(models.TextChoices): """Feedback status choices""" - SUBMITTED = 'submitted', 'Submitted' - REVIEWED = 'reviewed', 'Reviewed' - ACKNOWLEDGED = 'acknowledged', 'Acknowledged' - CLOSED = 'closed', 'Closed' + + SUBMITTED = "submitted", "Submitted" + REVIEWED = "reviewed", "Reviewed" + ACKNOWLEDGED = "acknowledged", "Acknowledged" + CLOSED = "closed", "Closed" class FeedbackCategory(models.TextChoices): """Feedback category choices""" - CLINICAL_CARE = 'clinical_care', 'Clinical Care' - STAFF_SERVICE = 'staff_service', 'Staff Service' - FACILITY = 'facility', 'Facility & Environment' - COMMUNICATION = 'communication', 'Communication' - APPOINTMENT = 'appointment', 'Appointment & Scheduling' - BILLING = 'billing', 'Billing & Insurance' - FOOD_SERVICE = 'food_service', 'Food Service' - CLEANLINESS = 'cleanliness', 'Cleanliness' - TECHNOLOGY = 'technology', 'Technology & Systems' - OTHER = 'other', 'Other' + + CLINICAL_CARE = "clinical_care", "Clinical Care" + STAFF_SERVICE = "staff_service", "Staff Service" + FACILITY = "facility", "Facility & Environment" + COMMUNICATION = "communication", "Communication" + APPOINTMENT = "appointment", "Appointment & Scheduling" + BILLING = "billing", "Billing & Insurance" + FOOD_SERVICE = "food_service", "Food Service" + CLEANLINESS = "cleanliness", "Cleanliness" + TECHNOLOGY = "technology", "Technology & Systems" + OTHER = "other", "Other" class SentimentChoices(models.TextChoices): """Sentiment analysis choices""" - POSITIVE = 'positive', 'Positive' - NEUTRAL = 'neutral', 'Neutral' - NEGATIVE = 'negative', 'Negative' + + POSITIVE = "positive", "Positive" + NEUTRAL = "neutral", "Neutral" + NEGATIVE = "negative", "Negative" class Feedback(UUIDModel, TimeStampedModel): @@ -62,14 +67,15 @@ class Feedback(UUIDModel, TimeStampedModel): 3. ACKNOWLEDGED - Response provided 4. CLOSED - Feedback closed """ + # Patient and encounter information patient = models.ForeignKey( - 'organizations.Patient', + "organizations.Patient", on_delete=models.CASCADE, - related_name='feedbacks', + related_name="feedbacks", null=True, blank=True, - help_text="Patient who provided feedback (optional for anonymous feedback)" + help_text="Patient who provided feedback (optional for anonymous feedback)", ) # Anonymous feedback support @@ -79,76 +85,51 @@ class Feedback(UUIDModel, TimeStampedModel): contact_phone = models.CharField(max_length=20, blank=True) encounter_id = models.CharField( - max_length=100, - blank=True, - db_index=True, - help_text="Related encounter ID if applicable" + max_length=100, blank=True, db_index=True, help_text="Related encounter ID if applicable" ) # Survey linkage (for satisfaction checks after negative surveys) related_survey = models.ForeignKey( - 'surveys.SurveyInstance', + "surveys.SurveyInstance", on_delete=models.SET_NULL, null=True, blank=True, - related_name='follow_up_feedbacks', - help_text="Survey that triggered this satisfaction check feedback" + related_name="follow_up_feedbacks", + help_text="Survey that triggered this satisfaction check feedback", ) # Organization - hospital = models.ForeignKey( - 'organizations.Hospital', - on_delete=models.CASCADE, - related_name='feedbacks' - ) + hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="feedbacks") department = models.ForeignKey( - 'organizations.Department', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='feedbacks' + "organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="feedbacks" ) staff = models.ForeignKey( - 'organizations.Staff', + "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, - related_name='feedbacks', - help_text="Staff member being mentioned in feedback" + related_name="feedbacks", + help_text="Staff member being mentioned in feedback", ) # Feedback details feedback_type = models.CharField( - max_length=20, - choices=FeedbackType.choices, - default=FeedbackType.GENERAL, - db_index=True + max_length=20, choices=FeedbackType.choices, default=FeedbackType.GENERAL, db_index=True ) title = models.CharField(max_length=500) message = models.TextField(help_text="Feedback message") # Classification - category = models.CharField( - max_length=50, - choices=FeedbackCategory.choices, - db_index=True - ) + category = models.CharField(max_length=50, choices=FeedbackCategory.choices, db_index=True) subcategory = models.CharField(max_length=100, blank=True) # Rating (1-5 stars) - rating = models.IntegerField( - null=True, - blank=True, - help_text="Rating from 1 to 5 stars" - ) + rating = models.IntegerField(null=True, blank=True, help_text="Rating from 1 to 5 stars") # Priority priority = models.CharField( - max_length=20, - choices=PriorityChoices.choices, - default=PriorityChoices.MEDIUM, - db_index=True + max_length=20, choices=PriorityChoices.choices, default=PriorityChoices.MEDIUM, db_index=True ) # Sentiment analysis @@ -157,83 +138,56 @@ class Feedback(UUIDModel, TimeStampedModel): choices=SentimentChoices.choices, default=SentimentChoices.NEUTRAL, db_index=True, - help_text="Sentiment analysis result" + help_text="Sentiment analysis result", ) sentiment_score = models.FloatField( - null=True, - blank=True, - help_text="Sentiment score from -1 (negative) to 1 (positive)" + null=True, blank=True, help_text="Sentiment score from -1 (negative) to 1 (positive)" ) # Status and workflow status = models.CharField( - max_length=20, - choices=FeedbackStatus.choices, - default=FeedbackStatus.SUBMITTED, - db_index=True + max_length=20, choices=FeedbackStatus.choices, default=FeedbackStatus.SUBMITTED, db_index=True ) # Assignment assigned_to = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='assigned_feedbacks' + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_feedbacks" ) assigned_at = models.DateTimeField(null=True, blank=True) # Review tracking reviewed_at = models.DateTimeField(null=True, blank=True) reviewed_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='reviewed_feedbacks' + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="reviewed_feedbacks" ) # Acknowledgment acknowledged_at = models.DateTimeField(null=True, blank=True) acknowledged_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='acknowledged_feedbacks' + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="acknowledged_feedbacks" ) # Closure closed_at = models.DateTimeField(null=True, blank=True) closed_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='closed_feedbacks' + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_feedbacks" ) # Flags - is_featured = models.BooleanField( - default=False, - help_text="Feature this feedback (e.g., for testimonials)" - ) - is_public = models.BooleanField( - default=False, - help_text="Make this feedback public" - ) + is_featured = models.BooleanField(default=False, help_text="Feature this feedback (e.g., for testimonials)") + is_public = models.BooleanField(default=False, help_text="Make this feedback public") requires_follow_up = models.BooleanField(default=False) - + # Source source = models.ForeignKey( - 'px_sources.PXSource', + "px_sources.PXSource", on_delete=models.PROTECT, - related_name='feedbacks', + related_name="feedbacks", null=True, blank=True, - help_text="Source of feedback" + help_text="Source of feedback", ) - + # Metadata metadata = models.JSONField(default=dict, blank=True) @@ -241,23 +195,19 @@ class Feedback(UUIDModel, TimeStampedModel): is_deleted = models.BooleanField(default=False, db_index=True) deleted_at = models.DateTimeField(null=True, blank=True) deleted_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='deleted_feedbacks' + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="deleted_feedbacks" ) class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] indexes = [ - models.Index(fields=['status', '-created_at']), - models.Index(fields=['hospital', 'status', '-created_at']), - models.Index(fields=['feedback_type', '-created_at']), - models.Index(fields=['sentiment', '-created_at']), - models.Index(fields=['is_deleted', '-created_at']), + models.Index(fields=["status", "-created_at"]), + models.Index(fields=["hospital", "status", "-created_at"]), + models.Index(fields=["feedback_type", "-created_at"]), + models.Index(fields=["sentiment", "-created_at"]), + models.Index(fields=["is_deleted", "-created_at"]), ] - verbose_name_plural = 'Feedback' + verbose_name_plural = "Feedback" def __str__(self): if self.patient: @@ -275,34 +225,27 @@ class Feedback(UUIDModel, TimeStampedModel): self.is_deleted = True self.deleted_at = timezone.now() self.deleted_by = user - self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by']) + self.save(update_fields=["is_deleted", "deleted_at", "deleted_by"]) class FeedbackAttachment(UUIDModel, TimeStampedModel): """Feedback attachment (images, documents, etc.)""" - feedback = models.ForeignKey( - Feedback, - on_delete=models.CASCADE, - related_name='attachments' - ) - file = models.FileField(upload_to='feedback/%Y/%m/%d/') + feedback = models.ForeignKey(Feedback, on_delete=models.CASCADE, related_name="attachments") + + file = models.FileField(upload_to="feedback/%Y/%m/%d/") filename = models.CharField(max_length=500) file_type = models.CharField(max_length=100, blank=True) file_size = models.IntegerField(help_text="File size in bytes") uploaded_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='feedback_attachments' + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="feedback_attachments" ) description = models.TextField(blank=True) class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] def __str__(self): return f"{self.feedback} - {self.filename}" @@ -314,33 +257,27 @@ class FeedbackResponse(UUIDModel, TimeStampedModel): Tracks all responses, status changes, and communications. """ - feedback = models.ForeignKey( - Feedback, - on_delete=models.CASCADE, - related_name='responses' - ) + + feedback = models.ForeignKey(Feedback, on_delete=models.CASCADE, related_name="responses") # Response details response_type = models.CharField( max_length=50, choices=[ - ('status_change', 'Status Change'), - ('assignment', 'Assignment'), - ('note', 'Internal Note'), - ('response', 'Response to Patient'), - ('acknowledgment', 'Acknowledgment'), + ("status_change", "Status Change"), + ("assignment", "Assignment"), + ("note", "Internal Note"), + ("response", "Response to Patient"), + ("acknowledgment", "Acknowledgment"), ], - db_index=True + db_index=True, ) message = models.TextField() # User who made the response created_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - related_name='feedback_responses' + "accounts.User", on_delete=models.SET_NULL, null=True, related_name="feedback_responses" ) # Status change tracking @@ -348,19 +285,360 @@ class FeedbackResponse(UUIDModel, TimeStampedModel): new_status = models.CharField(max_length=20, blank=True) # Visibility - is_internal = models.BooleanField( - default=False, - help_text="Internal note (not visible to patient)" - ) + is_internal = models.BooleanField(default=False, help_text="Internal note (not visible to patient)") # Metadata metadata = models.JSONField(default=dict, blank=True) class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] indexes = [ - models.Index(fields=['feedback', '-created_at']), + models.Index(fields=["feedback", "-created_at"]), ] def __str__(self): return f"{self.feedback} - {self.response_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}" + + +class CommentSourceCategory(models.TextChoices): + """Source category from IT department data export (Step 0)""" + + APPOINTMENT = "appointment", "Appointment" + INPATIENT = "inpatient", "Inpatient" + OUTPATIENT = "outpatient", "Outpatient" + + +class CommentClassification(models.TextChoices): + """Comment classification categories (Step 1 — Colors sheet)""" + + HOSPITAL = "hospital", "Hospital" + MEDICAL = "medical", "Medical" + NON_MEDICAL = "non_medical", "Non-Medical" + NURSING = "nursing", "Nursing" + ER = "er", "ER" + SUPPORT_SERVICES = "support_services", "Support Services" + + +class CommentSubCategory(models.TextChoices): + """Comment sub-categories (Step 1 — Colors sheet dropdown)""" + + PHARMACY = "pharmacy", "Pharmacy" + RAD = "rad", "RAD" + LAB = "lab", "LAB" + PHYSIOTHERAPY = "physiotherapy", "Physiotherapy" + DOCTORS = "doctors", "Doctors" + MEDICAL_REPORTS = "medical_reports", "Medical Reports" + RECEPTION = "reception", "Reception" + INSURANCE_APPROVALS = "insurance_approvals", "Insurance/Approvals" + OPD_CLINICS = "opd_clinics", "OPD - Clinics" + APPOINTMENTS = "appointments", "Appointments" + IT_APP = "it_app", "IT - App" + ADMINISTRATION = "administration", "Administration" + BILLING = "billing", "Billing" + FACILITIES = "facilities", "Facilities" + FOOD_SERVICES = "food_services", "Food Services" + PARKING = "parking", "Parking" + HOUSEKEEPING = "housekeeping", "Housekeeping" + OTHER = "other", "Other" + + +COMMENT_SUB_CATEGORY_MAP = { + CommentClassification.MEDICAL: [ + CommentSubCategory.PHARMACY, + CommentSubCategory.RAD, + CommentSubCategory.LAB, + CommentSubCategory.PHYSIOTHERAPY, + CommentSubCategory.DOCTORS, + CommentSubCategory.MEDICAL_REPORTS, + ], + CommentClassification.NON_MEDICAL: [ + CommentSubCategory.RECEPTION, + CommentSubCategory.INSURANCE_APPROVALS, + CommentSubCategory.OPD_CLINICS, + CommentSubCategory.APPOINTMENTS, + CommentSubCategory.IT_APP, + CommentSubCategory.ADMINISTRATION, + CommentSubCategory.BILLING, + ], + CommentClassification.NURSING: [], + CommentClassification.ER: [ + CommentSubCategory.DOCTORS, + CommentSubCategory.RECEPTION, + ], + CommentClassification.SUPPORT_SERVICES: [ + CommentSubCategory.FACILITIES, + CommentSubCategory.FOOD_SERVICES, + CommentSubCategory.PARKING, + CommentSubCategory.HOUSEKEEPING, + ], + CommentClassification.HOSPITAL: [], +} + + +class CommentImport(UUIDModel, TimeStampedModel): + """ + Tracks IT department comment data imports (Step 0). + + Each import represents a monthly batch of raw patient comments + exported from the IT system. + """ + + IMPORT_STATUS_CHOICES = [ + ("pending", "Pending"), + ("processing", "Processing"), + ("completed", "Completed"), + ("failed", "Failed"), + ] + + hospital = models.ForeignKey( + "organizations.Hospital", + on_delete=models.CASCADE, + related_name="comment_imports", + ) + + month = models.IntegerField(help_text="Month number (1-12)") + year = models.IntegerField(help_text="Year") + + source_file = models.FileField( + upload_to="comments/imports/%Y/%m/", + blank=True, + help_text="Uploaded source file from IT department", + ) + + status = models.CharField(max_length=20, choices=IMPORT_STATUS_CHOICES, default="pending") + + total_rows = models.IntegerField(default=0, help_text="Total rows in the import file") + imported_count = models.IntegerField(default=0, help_text="Number of comments successfully imported") + error_count = models.IntegerField(default=0) + error_log = models.TextField(blank=True) + + imported_by = models.ForeignKey( + "accounts.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="comment_imports", + ) + + class Meta: + ordering = ["-year", "-month"] + unique_together = [["hospital", "year", "month"]] + verbose_name = "Comment Import" + verbose_name_plural = "Comment Imports" + + def __str__(self): + return f"{self.hospital} - {self.year}-{self.month:02d} ({self.imported_count} comments)" + + +class PatientComment(UUIDModel, TimeStampedModel): + """ + Patient comment from IT department data export. + + Workflow (Steps 0-5): + - Step 0: Raw import from IT (source_category, comment_text) + - Step 1: Classification (classification, sub_category, sentiment keywords) + - Step 2: Department filtering (grouped by department for review) + - Step 3: Action plan creation (linked action plans) + - Step 5: Action plan tracking (status, timeframe, evidence) + + One comment can have multiple classifications (multi-category). + Use PatientCommentClassification for multi-category support. + """ + + comment_import = models.ForeignKey( + CommentImport, + on_delete=models.CASCADE, + related_name="comments", + null=True, + blank=True, + help_text="Import batch this comment came from", + ) + + hospital = models.ForeignKey( + "organizations.Hospital", + on_delete=models.CASCADE, + related_name="patient_comments", + ) + + serial_number = models.IntegerField(null=True, blank=True, help_text="Serial number from the source file") + + source_category = models.CharField( + max_length=20, + choices=CommentSourceCategory.choices, + blank=True, + db_index=True, + help_text="Source category from IT export (Appointment/Inpatient/Outpatient)", + ) + + comment_text = models.TextField(help_text="Original comment text (Arabic/English)") + + comment_text_en = models.TextField(blank=True, help_text="English translation of the comment") + + classification = models.CharField( + max_length=20, + choices=CommentClassification.choices, + blank=True, + db_index=True, + help_text="Primary classification (Hospital/Medical/Non-Medical/Nursing/ER/Support)", + ) + + sub_category = models.CharField( + max_length=30, + choices=CommentSubCategory.choices, + blank=True, + db_index=True, + help_text="Sub-category classification (e.g., Pharmacy, Reception, etc.)", + ) + + negative_keywords = models.TextField(blank=True, help_text="Negative sentiment keywords/phrases extracted") + + positive_keywords = models.TextField(blank=True, help_text="Positive sentiment keywords/phrases extracted") + + gratitude_keywords = models.TextField(blank=True, help_text="Gratitude keywords/phrases extracted") + + suggestions = models.TextField(blank=True, help_text="Suggestion text extracted from the comment") + + sentiment = models.CharField( + max_length=20, + choices=SentimentChoices.choices, + blank=True, + db_index=True, + help_text="Overall sentiment classification", + ) + + is_classified = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this comment has been classified", + ) + + mentioned_doctor_name = models.CharField( + max_length=200, + blank=True, + help_text="Name of doctor mentioned in the comment", + ) + + mentioned_doctor_name_en = models.CharField( + max_length=200, + blank=True, + help_text="English name of mentioned doctor", + ) + + frequency = models.IntegerField( + default=1, + help_text="How many times this comment/problem has been reported", + ) + + month = models.IntegerField(null=True, blank=True, help_text="Month the comment was collected") + + year = models.IntegerField(null=True, blank=True, help_text="Year the comment was collected") + + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ["-year", "-month", "-serial_number"] + indexes = [ + models.Index(fields=["hospital", "classification", "sub_category"]), + models.Index(fields=["hospital", "year", "month"]), + models.Index(fields=["hospital", "source_category"]), + models.Index(fields=["sentiment"]), + ] + verbose_name = "Patient Comment" + verbose_name_plural = "Patient Comments" + + def __str__(self): + text = self.comment_text[:50] if self.comment_text else "No text" + return f"#{self.serial_number or '—'} {text}" + + +class CommentActionPlanStatus(models.TextChoices): + COMPLETED = "completed", "Completed" + ON_PROCESS = "on_process", "On Process" + PENDING = "pending", "Pending" + + +class CommentActionPlan(UUIDModel, TimeStampedModel): + """ + Action plan derived from classified patient comments (Step 3/5). + + Tracks recommendations/action plans with responsible departments, + timeframes, and completion status. + """ + + comment = models.ForeignKey( + PatientComment, + on_delete=models.CASCADE, + related_name="action_plans", + null=True, + blank=True, + help_text="Source comment (may be null if aggregated from multiple)", + ) + + hospital = models.ForeignKey( + "organizations.Hospital", + on_delete=models.CASCADE, + related_name="comment_action_plans", + ) + + department = models.ForeignKey( + "organizations.Department", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="comment_action_plans", + help_text="Responsible department", + ) + + department_label = models.CharField( + max_length=200, + blank=True, + help_text="Free-text department name (for when no FK exists)", + ) + + problem_number = models.IntegerField(null=True, blank=True, help_text="Problem number within the department") + + comment_text = models.TextField(blank=True, help_text="Related comment text") + comment_text_en = models.TextField(blank=True, help_text="English translation of the comment") + + frequency = models.IntegerField(default=1, help_text="Number of times this problem was reported") + + recommendation = models.TextField(help_text="Recommendation / Action Plan") + + recommendation_en = models.TextField(blank=True, help_text="English translation of the recommendation") + + responsible_department = models.CharField( + max_length=200, + blank=True, + help_text="Free-text responsible department name", + ) + + status = models.CharField( + max_length=20, + choices=CommentActionPlanStatus.choices, + default=CommentActionPlanStatus.PENDING, + db_index=True, + ) + + timeframe = models.CharField( + max_length=100, + blank=True, + help_text="Target timeframe (e.g., Q3, 3 months, 2025-Q1)", + ) + + evidences = models.TextField(blank=True, help_text="Evidence of completion / notes") + + month = models.IntegerField(null=True, blank=True) + year = models.IntegerField(null=True, blank=True) + + class Meta: + ordering = ["department_label", "problem_number"] + indexes = [ + models.Index(fields=["hospital", "status"]), + models.Index(fields=["hospital", "year", "month"]), + ] + verbose_name = "Comment Action Plan" + verbose_name_plural = "Comment Action Plans" + + def __str__(self): + dept = self.department_label or (self.department.name if self.department else "—") + return f"{dept} - Problem #{self.problem_number or '—'} ({self.get_status_display()})" diff --git a/apps/feedback/urls.py b/apps/feedback/urls.py index 7f2b766..9652c60 100644 --- a/apps/feedback/urls.py +++ b/apps/feedback/urls.py @@ -1,28 +1,33 @@ """ Feedback URL Configuration """ + from django.urls import path from . import views -app_name = 'feedback' +app_name = "feedback" urlpatterns = [ # List and detail views - path('', views.feedback_list, name='feedback_list'), - path('/', views.feedback_detail, name='feedback_detail'), - + path("", views.feedback_list, name="feedback_list"), + path("/", views.feedback_detail, name="feedback_detail"), # CRUD operations - path('create/', views.feedback_create, name='feedback_create'), - path('/update/', views.feedback_update, name='feedback_update'), - path('/delete/', views.feedback_delete, name='feedback_delete'), - + path("create/", views.feedback_create, name="feedback_create"), + path("/update/", views.feedback_update, name="feedback_update"), + path("/delete/", views.feedback_delete, name="feedback_delete"), # Workflow actions - path('/assign/', views.feedback_assign, name='feedback_assign'), - path('/change-status/', views.feedback_change_status, name='feedback_change_status'), - path('/add-response/', views.feedback_add_response, name='feedback_add_response'), - + path("/assign/", views.feedback_assign, name="feedback_assign"), + path("/change-status/", views.feedback_change_status, name="feedback_change_status"), + path("/add-response/", views.feedback_add_response, name="feedback_add_response"), # Toggle actions - path('/toggle-featured/', views.feedback_toggle_featured, name='feedback_toggle_featured'), - path('/toggle-follow-up/', views.feedback_toggle_follow_up, name='feedback_toggle_follow_up'), + path("/toggle-featured/", views.feedback_toggle_featured, name="feedback_toggle_featured"), + path("/toggle-follow-up/", views.feedback_toggle_follow_up, name="feedback_toggle_follow_up"), + # Comments Workflow + path("comments/imports/", views.comment_import_list, name="comment_import_list"), + path("comments/", views.comment_list, name="comment_list"), + path("comments/action-plans/", views.action_plan_list, name="action_plan_list"), + path("comments/export/classification/", views.export_comments_step1, name="export_comments_step1"), + path("comments/export/filtered/", views.export_comments_step2, name="export_comments_step2"), + path("comments/export/action-plans/", views.export_action_plans, name="export_action_plans"), ] diff --git a/apps/feedback/views.py b/apps/feedback/views.py index 8ebeac3..2be5b87 100644 --- a/apps/feedback/views.py +++ b/apps/feedback/views.py @@ -391,7 +391,212 @@ def feedback_delete(request, pk): except Exception as e: messages.error(request, f"Error deleting feedback: {str(e)}") - return redirect("feedback:feedback_detail", pk=pk) + return redirect("feedback:feedback_detail", pk=pk) + + +@login_required +def comment_import_list(request): + """ + Step 0 — Comment Imports list view. + """ + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied + + from .models import CommentImport + + qs = CommentImport.objects.select_related("hospital", "imported_by") + if request.user.is_hospital_admin() and request.user.hospital: + qs = qs.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(hospital=request.tenant_hospital) + + qs = qs.order_by("-year", "-month") + return render(request, "feedback/comment_import_list.html", {"imports": qs}) + + +@login_required +def comment_list(request): + """ + Step 1 — Classified Patient Comments list view with filters. + """ + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied + + from .models import PatientComment + + qs = PatientComment.objects.select_related("hospital") + if request.user.is_hospital_admin() and request.user.hospital: + qs = qs.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(hospital=request.tenant_hospital) + + classification = request.GET.get("classification") + sub_category = request.GET.get("sub_category") + sentiment = request.GET.get("sentiment") + year = request.GET.get("year") + month = request.GET.get("month") + source_category = request.GET.get("source_category") + + if classification: + qs = qs.filter(classification=classification) + if sub_category: + qs = qs.filter(sub_category=sub_category) + if sentiment: + qs = qs.filter(sentiment=sentiment) + if year: + qs = qs.filter(year=int(year)) + if month: + qs = qs.filter(month=int(month)) + if source_category: + qs = qs.filter(source_category=source_category) + + if not request.GET.get("classified_only"): + qs = qs.filter(is_classified=True) + + qs = qs.order_by("-year", "-month", "-serial_number") + + from django.core.paginator import Paginator + + paginator = Paginator(qs, 50) + page = request.GET.get("page", 1) + page_obj = paginator.get_page(page) + + from .models import CommentClassification, CommentSubCategory, CommentSourceCategory, SentimentChoices + + context = { + "page_obj": page_obj, + "classifications": CommentClassification.choices, + "sub_categories": CommentSubCategory.choices, + "sentiments": SentimentChoices.choices, + "source_categories": CommentSourceCategory.choices, + "years_range": range(2024, timezone.now().year + 1), + "months_range": range(1, 13), + "selected_classification": classification, + "selected_sub_category": sub_category, + "selected_sentiment": sentiment, + "selected_year": year, + "selected_month": month, + "selected_source_category": source_category, + } + return render(request, "feedback/comment_list.html", context) + + +@login_required +def action_plan_list(request): + """ + Step 5 — Comment Action Plan tracking view. + """ + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied + + from .models import CommentActionPlan + + qs = CommentActionPlan.objects.select_related("hospital", "department") + if request.user.is_hospital_admin() and request.user.hospital: + qs = qs.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(hospital=request.tenant_hospital) + + status_filter = request.GET.get("status") + year = request.GET.get("year") + + if status_filter: + qs = qs.filter(status=status_filter) + if year: + qs = qs.filter(year=int(year)) + + qs = qs.order_by("department_label", "problem_number") + + context = { + "action_plans": qs, + "selected_status": status_filter, + "selected_year": year, + "years_range": range(2024, timezone.now().year + 1), + } + return render(request, "feedback/action_plan_list.html", context) + + +@login_required +def export_comments_step1(request): + """ + Step 1 — Export classified comments to Excel. + """ + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied + + from .models import PatientComment + from .export_utils import export_classified_comments + + qs = PatientComment.objects.filter(is_classified=True).select_related("hospital") + if request.user.is_hospital_admin() and request.user.hospital: + qs = qs.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(hospital=request.tenant_hospital) + + year = request.GET.get("year") + month = request.GET.get("month") + if year: + qs = qs.filter(year=int(year)) + if month: + qs = qs.filter(month=int(month)) + + return export_classified_comments(qs) + + +@login_required +def export_comments_step2(request): + """ + Step 2 — Export filtered comments by department to Excel. + """ + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied + + from .models import PatientComment + from .export_utils import export_filtered_comments_by_dept + + qs = PatientComment.objects.filter(is_classified=True).select_related("hospital") + if request.user.is_hospital_admin() and request.user.hospital: + qs = qs.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(hospital=request.tenant_hospital) + + year = request.GET.get("year") + if year: + qs = qs.filter(year=int(year)) + + return export_filtered_comments_by_dept(qs) + + +@login_required +def export_action_plans(request): + """ + Step 3/5 — Export action plans to Excel. + """ + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied + + from .models import CommentActionPlan + from .export_utils import export_action_plans + + qs = CommentActionPlan.objects.select_related("hospital", "department") + if request.user.is_hospital_admin() and request.user.hospital: + qs = qs.filter(hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(hospital=request.tenant_hospital) + + return export_action_plans(qs) context = { "feedback": feedback, diff --git a/apps/integrations/admin.py b/apps/integrations/admin.py index db67c32..36f0809 100644 --- a/apps/integrations/admin.py +++ b/apps/integrations/admin.py @@ -1,67 +1,58 @@ """ Integrations admin """ -from django.contrib import admin -from django.utils.html import format_html -from .models import EventMapping, InboundEvent, IntegrationConfig, SurveyTemplateMapping +from django.contrib import admin +from django.utils.html import format_html, mark_safe + +from .models import ( + EventMapping, + HISEventType, + HISTestPatient, + HISTestVisit, + HISPatientVisit, + HISVisitEvent, + InboundEvent, + IntegrationConfig, + SurveyTemplateMapping, +) @admin.register(InboundEvent) class InboundEventAdmin(admin.ModelAdmin): """Inbound event admin""" - list_display = [ - 'source_system', 'event_code', 'encounter_id', - 'status_badge', 'processing_attempts', 'received_at' - ] - list_filter = ['status', 'source_system', 'received_at'] - search_fields = ['encounter_id', 'patient_identifier', 'event_code'] - ordering = ['-received_at'] - date_hierarchy = 'received_at' - + + list_display = ["source_system", "event_code", "encounter_id", "status_badge", "processing_attempts", "received_at"] + list_filter = ["status", "source_system", "received_at"] + search_fields = ["encounter_id", "patient_identifier", "event_code"] + ordering = ["-received_at"] + date_hierarchy = "received_at" + fieldsets = ( - ('Event Information', { - 'fields': ('source_system', 'event_code', 'encounter_id', 'patient_identifier') - }), - ('Payload', { - 'fields': ('payload_json',), - 'classes': ('collapse',) - }), - ('Processing Status', { - 'fields': ('status', 'processing_attempts', 'error') - }), - ('Extracted Context', { - 'fields': ('physician_license', 'department_code'), - 'classes': ('collapse',) - }), - ('Timestamps', { - 'fields': ('received_at', 'processed_at', 'created_at', 'updated_at') - }), - ('Metadata', { - 'fields': ('metadata',), - 'classes': ('collapse',) - }), + ("Event Information", {"fields": ("source_system", "event_code", "encounter_id", "patient_identifier")}), + ("Payload", {"fields": ("payload_json",), "classes": ("collapse",)}), + ("Processing Status", {"fields": ("status", "processing_attempts", "error")}), + ("Extracted Context", {"fields": ("physician_license", "department_code"), "classes": ("collapse",)}), + ("Timestamps", {"fields": ("received_at", "processed_at", "created_at", "updated_at")}), + ("Metadata", {"fields": ("metadata",), "classes": ("collapse",)}), ) - - readonly_fields = ['received_at', 'processed_at', 'created_at', 'updated_at'] - + + readonly_fields = ["received_at", "processed_at", "created_at", "updated_at"] + def status_badge(self, obj): """Display status with color badge""" colors = { - 'pending': 'warning', - 'processing': 'info', - 'processed': 'success', - 'failed': 'danger', - 'ignored': 'secondary', + "pending": "warning", + "processing": "info", + "processed": "success", + "failed": "danger", + "ignored": "secondary", } - color = colors.get(obj.status, 'secondary') - return format_html( - '{}', - color, - obj.get_status_display() - ) - status_badge.short_description = 'Status' - + color = colors.get(obj.status, "secondary") + return format_html('{}', color, obj.get_status_display()) + + status_badge.short_description = "Status" + def has_add_permission(self, request): # Events should only be created via API return False @@ -69,98 +60,203 @@ class InboundEventAdmin(admin.ModelAdmin): class EventMappingInline(admin.TabularInline): """Inline admin for event mappings""" + model = EventMapping extra = 1 - fields = ['external_event_code', 'internal_event_code', 'is_active'] + fields = ["external_event_code", "internal_event_code", "is_active"] @admin.register(IntegrationConfig) class IntegrationConfigAdmin(admin.ModelAdmin): """Integration configuration admin""" - list_display = ['name', 'source_system', 'is_active', 'last_sync_at', 'created_at'] - list_filter = ['source_system', 'is_active'] - search_fields = ['name', 'description'] - ordering = ['name'] + + list_display = ["name", "source_system", "is_active", "last_sync_at", "created_at"] + list_filter = ["source_system", "is_active"] + search_fields = ["name", "description"] + ordering = ["name"] inlines = [EventMappingInline] - + fieldsets = ( - (None, { - 'fields': ('name', 'source_system', 'description') - }), - ('Connection', { - 'fields': ('api_url', 'api_key'), - 'classes': ('collapse',) - }), - ('Configuration', { - 'fields': ('is_active', 'config_json'), - 'classes': ('collapse',) - }), - ('Metadata', { - 'fields': ('last_sync_at', 'created_at', 'updated_at') - }), + (None, {"fields": ("name", "source_system", "description")}), + ("Connection", {"fields": ("api_url", "api_key"), "classes": ("collapse",)}), + ("Configuration", {"fields": ("is_active", "config_json"), "classes": ("collapse",)}), + ("Metadata", {"fields": ("last_sync_at", "created_at", "updated_at")}), ) - - readonly_fields = ['last_sync_at', 'created_at', 'updated_at'] + + readonly_fields = ["last_sync_at", "created_at", "updated_at"] @admin.register(SurveyTemplateMapping) class SurveyTemplateMappingAdmin(admin.ModelAdmin): """Survey template mapping admin""" - list_display = [ - 'hospital', 'patient_type', 'survey_template', 'is_active' - ] - list_filter = ['hospital', 'patient_type', 'is_active'] - search_fields = ['hospital__name', 'survey_template__name'] - ordering = ['hospital', 'patient_type'] - + + list_display = ["hospital", "patient_type", "survey_template", "is_active"] + list_filter = ["hospital", "patient_type", "is_active"] + search_fields = ["hospital__name", "survey_template__name"] + ordering = ["hospital", "patient_type"] + fieldsets = ( - ('Mapping Configuration', { - 'fields': ('hospital', 'patient_type', 'survey_template') - }), - ('Settings', { - 'fields': ('is_active',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + ("Mapping Configuration", {"fields": ("hospital", "patient_type", "survey_template")}), + ("Settings", {"fields": ("is_active",)}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('hospital', 'survey_template') + return qs.select_related("hospital", "survey_template") + + +class HISVisitEventInline(admin.TabularInline): + model = HISVisitEvent + extra = 0 + fields = ["event_type", "bill_date", "parsed_date", "visit_category"] + readonly_fields = ["event_type", "bill_date", "parsed_date", "visit_category"] + show_change_link = False + + +@admin.register(HISPatientVisit) +class HISPatientVisitAdmin(admin.ModelAdmin): + """HIS patient visit admin""" + + list_display = [ + "patient", + "patient_type", + "admission_id", + "hospital", + "is_visit_complete", + "survey_linked", + "discharge_date", + "last_his_fetch_at", + ] + list_filter = ["patient_type", "is_visit_complete", "hospital"] + search_fields = ["admission_id", "reg_code", "patient_id_his", "patient__first_name", "patient__last_name"] + ordering = ["-last_his_fetch_at"] + date_hierarchy = "last_his_fetch_at" + inlines = [HISVisitEventInline] + + fieldsets = ( + ( + "Patient & Hospital", + {"fields": ("patient", "hospital", "admission_id", "reg_code", "patient_id_his", "patient_type")}, + ), + ("Visit Dates", {"fields": ("admit_date", "discharge_date", "effective_discharge_date")}), + ("Status", {"fields": ("is_visit_complete", "survey_instance", "last_his_fetch_at")}), + ("HIS Data", {"fields": ("visit_data", "visit_timeline"), "classes": ("collapse",)}), + ("Doctor & Consultant", {"fields": ("primary_doctor", "consultant_id", "primary_doctor_fk", "consultant_fk")}), + ("Insurance & Billing", {"fields": ("company_name", "grade_name", "insurance_company_name", "bill_type")}), + ("VIP & Nationality", {"fields": ("is_vip", "nationality")}), + ("Timestamps", {"fields": ("created_at", "updated_at")}), + ) + + readonly_fields = ["created_at", "updated_at"] + + def survey_linked(self, obj): + if obj.survey_instance: + return format_html( + '{}', obj.survey_instance.id, obj.survey_instance.id + ) + return mark_safe('None') + + survey_linked.short_description = "Survey" + + def has_add_permission(self, request): + return False + + +@admin.register(HISVisitEvent) +class HISVisitEventAdmin(admin.ModelAdmin): + """HIS visit event admin""" + + list_display = ["visit", "event_type", "bill_date", "parsed_date", "visit_category", "admission_id"] + list_filter = ["visit_category", "patient_type"] + search_fields = ["admission_id", "patient_id", "event_type"] + ordering = ["-parsed_date"] + date_hierarchy = "parsed_date" + + fieldsets = ( + ("Visit", {"fields": ("visit",)}), + ("Event Details", {"fields": ("event_type", "bill_date", "parsed_date", "visit_category")}), + ( + "Patient Info", + {"fields": ("patient_type", "admission_id", "patient_id", "reg_code", "ssn", "mobile_no")}, + ), + ("Timestamps", {"fields": ("created_at", "updated_at")}), + ) + + readonly_fields = ["created_at", "updated_at"] + + def has_add_permission(self, request): + return False + + +@admin.register(HISEventType) +class HISEventTypeAdmin(admin.ModelAdmin): + """HIS event type admin - auto-populated from HIS data""" + + list_display = ["event_type", "patient_types_display", "event_count", "last_seen_at"] + search_fields = ["event_type"] + ordering = ["event_type"] + list_filter = ["last_seen_at"] + + readonly_fields = ["event_type", "patient_types", "event_count", "last_seen_at", "created_at", "updated_at"] + + def has_add_permission(self, request): + return False + + def patient_types_display(self, obj): + if not obj.patient_types: + return mark_safe('None') + return ", ".join(obj.patient_types) + + patient_types_display.short_description = "Patient Types" @admin.register(EventMapping) class EventMappingAdmin(admin.ModelAdmin): """Event mapping admin""" - list_display = [ - 'integration_config', 'external_event_code', - 'internal_event_code', 'is_active' - ] - list_filter = ['integration_config', 'is_active'] - search_fields = ['external_event_code', 'internal_event_code'] - ordering = ['integration_config', 'external_event_code'] - + + list_display = ["integration_config", "external_event_code", "internal_event_code", "is_active"] + list_filter = ["integration_config", "is_active"] + search_fields = ["external_event_code", "internal_event_code"] + ordering = ["integration_config", "external_event_code"] + fieldsets = ( - (None, { - 'fields': ('integration_config', 'external_event_code', 'internal_event_code') - }), - ('Field Mappings', { - 'fields': ('field_mappings',), - 'classes': ('collapse',) - }), - ('Configuration', { - 'fields': ('is_active',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("integration_config", "external_event_code", "internal_event_code")}), + ("Field Mappings", {"fields": ("field_mappings",), "classes": ("collapse",)}), + ("Configuration", {"fields": ("is_active",)}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('integration_config') + return qs.select_related("integration_config") + + +@admin.register(HISTestPatient) +class HISTestPatientAdmin(admin.ModelAdmin): + list_display = ["admission_id", "patient_name", "patient_type", "hospital_name", "admit_date", "discharge_date"] + list_filter = ["patient_type", "hospital_name"] + search_fields = ["admission_id", "patient_id", "ssn", "mobile_no", "patient_name"] + ordering = ["-admit_date"] + date_hierarchy = "admit_date" + readonly_fields = ["created_at", "updated_at"] + + def has_add_permission(self, request): + return False + + +@admin.register(HISTestVisit) +class HISTestVisitAdmin(admin.ModelAdmin): + list_display = ["admission_id", "visit_category", "event_type", "bill_date"] + list_filter = ["visit_category", "event_type"] + search_fields = ["admission_id", "patient_id"] + ordering = ["-bill_date"] + date_hierarchy = "bill_date" + readonly_fields = ["created_at", "updated_at"] + + def has_add_permission(self, request): + return False diff --git a/apps/integrations/management/commands/fetch_his_surveys.py b/apps/integrations/management/commands/fetch_his_surveys.py index b6547c2..6d8285c 100644 --- a/apps/integrations/management/commands/fetch_his_surveys.py +++ b/apps/integrations/management/commands/fetch_his_surveys.py @@ -1,14 +1,28 @@ """ -Management command to manually fetch surveys from HIS system. +Management command to manually fetch patient data from HIS system. Usage: - python manage.py fetch_his_surveys [--minutes N] [--limit N] [--test] + python manage.py fetch_his_surveys [--minutes N] [--from-date DATE] [--to-date DATE] [--test] Options: - --minutes N Fetch patients discharged in the last N minutes (default: 10) - --limit N Maximum number of patients to fetch (default: 100) - --test Test connection only, don't fetch surveys + --minutes N Fetch patients from the last N minutes (default: 10) + --from-date DATE Fetch patients from this date (DD-Mon-YYYY HH:MM:SS) + --to-date DATE Fetch patients until this date (DD-Mon-YYYY HH:MM:SS) + --test Test connection only, don't fetch data + --config NAME Configuration name to use (optional) + +Examples: + # Relative: last 10 minutes + python manage.py fetch_his_surveys --minutes 10 + + # Absolute: simulate a date range (for testing against test endpoint) + python manage.py fetch_his_surveys --config "HIS Test" \ + --from-date "01-Jan-2026 00:00:00" --to-date "01-Jan-2026 02:00:00" + + # Test connection + python manage.py fetch_his_surveys --config "HIS Test" --test """ + from django.core.management.base import BaseCommand from django.utils import timezone @@ -17,166 +31,159 @@ from apps.integrations.services.his_adapter import HISAdapter class Command(BaseCommand): - help = 'Fetch surveys from HIS system' + help = "Fetch patient data from HIS system" def add_arguments(self, parser): parser.add_argument( - '--minutes', + "--minutes", type=int, default=10, - help='Fetch patients discharged in the last N minutes (default: 10)' + help="Fetch patients from the last N minutes (default: 10)", ) parser.add_argument( - '--limit', - type=int, - default=100, - help='Maximum number of patients to fetch (default: 100)' - ) - parser.add_argument( - '--test', - action='store_true', - help='Test connection only, don\'t fetch surveys' - ) - parser.add_argument( - '--config', + "--from-date", type=str, - help='Configuration name to use (optional)' + default=None, + help="Fetch patients from this date (DD-Mon-YYYY HH:MM:SS)", + ) + parser.add_argument( + "--to-date", + type=str, + default=None, + help="Fetch patients until this date (DD-Mon-YYYY HH:MM:SS)", + ) + parser.add_argument( + "--test", + action="store_true", + help="Test connection only, don't fetch data", + ) + parser.add_argument( + "--config", + type=str, + help="Configuration name to use (optional)", ) def handle(self, *args, **options): - minutes = options['minutes'] - limit = options['limit'] - test_only = options['test'] - config_name = options.get('config') + minutes = options["minutes"] + from_date_str = options.get("from_date") + to_date_str = options.get("to_date") + test_only = options["test"] + config_name = options.get("config") + + self.stdout.write(self.style.MIGRATE_HEADING("=" * 70)) + self.stdout.write(self.style.MIGRATE_HEADING("HIS Patient Data Fetch")) + self.stdout.write(self.style.MIGRATE_HEADING("=" * 70)) + + if from_date_str and to_date_str: + self.stdout.write(f"Mode: custom date range") + self.stdout.write(f" From: {from_date_str}") + self.stdout.write(f" To: {to_date_str}") + else: + self.stdout.write(f"Mode: relative (last {minutes} minutes)") - self.stdout.write(self.style.MIGRATE_HEADING('=' * 70)) - self.stdout.write(self.style.MIGRATE_HEADING('HIS Survey Fetch')) - self.stdout.write(self.style.MIGRATE_HEADING('=' * 70)) - - # Get clients if config_name: - from apps.integrations.models import IntegrationConfig, SourceSystem - config = IntegrationConfig.objects.filter( - name=config_name, - source_system=SourceSystem.HIS - ).first() + from apps.integrations.models import IntegrationConfig + + config = IntegrationConfig.objects.filter(name=config_name).first() if not config: - self.stdout.write( - self.style.ERROR(f'Configuration "{config_name}" not found') - ) + self.stdout.write(self.style.ERROR(f'Configuration "{config_name}" not found')) return clients = [HISClient(config)] else: clients = HISClientFactory.get_all_active_clients() - + if not clients: - self.stdout.write( - self.style.ERROR('No active HIS configurations found') - ) + self.stdout.write(self.style.ERROR("No active HIS configurations found")) return - - self.stdout.write(f'Found {len(clients)} HIS configuration(s)') - + + self.stdout.write(f"Found {len(clients)} HIS configuration(s)") + total_patients = 0 + total_visits_saved = 0 total_surveys_created = 0 - total_surveys_sent = 0 - + for client in clients: - config_display = client.config.name if client.config else 'Default' - self.stdout.write(self.style.MIGRATE_HEADING(f'\n📡 Configuration: {config_display}')) - - # Test connection - self.stdout.write('Testing connection...', ending=' ') + config_display = client.config.name if client.config else "Default" + self.stdout.write(self.style.MIGRATE_HEADING(f"\nConfiguration: {config_display}")) + + self.stdout.write("Testing connection...", ending=" ") test_result = client.test_connection() - - if test_result['success']: - self.stdout.write(self.style.SUCCESS('✓ Connected')) + + if test_result["success"]: + self.stdout.write(self.style.SUCCESS("Connected")) else: - self.stdout.write( - self.style.ERROR(f'✗ Failed: {test_result["message"]}') - ) + self.stdout.write(self.style.ERROR(f"Failed: {test_result['message']}")) continue - + if test_only: continue - - # Calculate fetch window + from datetime import timedelta - fetch_since = timezone.now() - timedelta(minutes=minutes) - - self.stdout.write( - f'Fetching discharged patients since {fetch_since.strftime("%Y-%m-%d %H:%M:%S")}...' - ) - - # Fetch patients - patients = client.fetch_discharged_patients(since=fetch_since, limit=limit) - - if not patients: - self.stdout.write(self.style.WARNING('No patients found')) + from apps.integrations.tasks import _parse_his_date + + if from_date_str and to_date_str: + fetch_since = _parse_his_date(from_date_str) + fetch_until = _parse_his_date(to_date_str) + if not fetch_since or not fetch_until: + self.stdout.write(self.style.ERROR("Invalid date format. Use DD-Mon-YYYY HH:MM:SS")) + return + time_display = f"{fetch_since.strftime('%Y-%m-%d %H:%M')} to {fetch_until.strftime('%Y-%m-%d %H:%M')}" + else: + fetch_since = timezone.now() - timedelta(minutes=minutes) + fetch_until = None + time_display = f"{fetch_since.strftime('%Y-%m-%d %H:%M:%S')}" + + self.stdout.write(f"Fetching patient data: {time_display}") + + his_data = client.fetch_patient_data(since=fetch_since, until=fetch_until) + + if not his_data: + self.stdout.write(self.style.WARNING("No data returned from HIS")) continue - - self.stdout.write(f'Found {len(patients)} patient(s)') - - # Process each patient - for i, patient_data in enumerate(patients, 1): - self.stdout.write(f'\n Processing patient {i}/{len(patients)}...') - - try: - # Ensure proper format - if isinstance(patient_data, dict): - if 'FetchPatientDataTimeStampList' not in patient_data: - patient_data = { - 'FetchPatientDataTimeStampList': [patient_data], - 'FetchPatientDataTimeStampVisitDataList': [], - 'Code': 200, - 'Status': 'Success' - } - - # Process - result = HISAdapter.process_his_data(patient_data) - - if result['success']: - total_surveys_created += 1 - - patient_name = "Unknown" - if result.get('patient'): - patient_name = f"{result['patient'].first_name} {result['patient'].last_name}".strip() - - if result.get('survey_sent'): - total_surveys_sent += 1 - self.stdout.write( - f' {self.style.SUCCESS("✓")} Survey sent to {patient_name}' - ) - else: - self.stdout.write( - f' {self.style.WARNING("⚠")} Survey created but not sent to {patient_name}' - ) - else: - self.stdout.write( - f' {self.style.ERROR("✗")} {result.get("message", "Unknown error")}' - ) - - except Exception as e: - self.stdout.write( - f' {self.style.ERROR("✗")} Error: {str(e)}' - ) - - total_patients += len(patients) - - # Update last sync + + patient_list = his_data.get("FetchPatientDataTimeStampList", []) + + if not patient_list: + self.stdout.write(self.style.WARNING("No patients found")) + continue + + self.stdout.write(f"Found {len(patient_list)} patient(s)") + + process_result = HISAdapter.process_his_response(his_data) + + total_patients += len(patient_list) + total_visits_saved += process_result.get("visits_saved", 0) + total_surveys_created += process_result.get("surveys_created", 0) + + for detail in process_result.get("details", []): + name = detail.get("patient_name", "Unknown") + ptype = detail.get("patient_type", "") + adm_id = detail.get("admission_id", "") + + if detail.get("survey_created"): + self.stdout.write(f" {self.style.SUCCESS('+')} {name} ({ptype}) adm={adm_id} - survey created") + elif detail.get("error"): + self.stdout.write(f" {self.style.ERROR('x')} {name} ({ptype}) adm={adm_id} - {detail['error']}") + else: + reason = detail.get("reason", "Visit in progress") + self.stdout.write(f" - {name} ({ptype}) adm={adm_id} - {reason}") + + if process_result.get("errors"): + for err in process_result["errors"]: + self.stdout.write(self.style.WARNING(f" Error: {err}")) + if client.config: client.config.last_sync_at = timezone.now() - client.config.save(update_fields=['last_sync_at']) - - # Summary - self.stdout.write(self.style.MIGRATE_HEADING('\n' + '=' * 70)) - self.stdout.write(self.style.MIGRATE_HEADING('Summary')) - self.stdout.write(self.style.MIGRATE_HEADING('=' * 70)) - self.stdout.write(f'Total patients fetched: {total_patients}') - self.stdout.write(f'Total surveys created: {total_surveys_created}') - self.stdout.write(f'Total surveys sent: {total_surveys_sent}') - + client.config.save(update_fields=["last_sync_at"]) + + self.stdout.write(self.style.MIGRATE_HEADING("\n" + "=" * 70)) + self.stdout.write(self.style.MIGRATE_HEADING("Summary")) + self.stdout.write(self.style.MIGRATE_HEADING("=" * 70)) + self.stdout.write(f"Total patients fetched: {total_patients}") + self.stdout.write(f"Visits saved/updated: {total_visits_saved}") + self.stdout.write(f"Surveys created: {total_surveys_created}") + if test_only: - self.stdout.write(self.style.SUCCESS('\nConnection test completed successfully!')) + self.stdout.write(self.style.SUCCESS("\nConnection test completed successfully!")) else: - self.stdout.write(self.style.SUCCESS('\nFetch completed!')) + self.stdout.write(self.style.SUCCESS("\nFetch completed!")) diff --git a/apps/integrations/management/commands/load_his_test_data.py b/apps/integrations/management/commands/load_his_test_data.py new file mode 100644 index 0000000..aadda22 --- /dev/null +++ b/apps/integrations/management/commands/load_his_test_data.py @@ -0,0 +1,193 @@ +""" +Management command to load visit_data.json into HISTestPatient and HISTestVisit tables. + +Usage: + python manage.py load_his_test_data [--clear] [--file path/to/visit_data.json] + +Options: + --clear Clear existing test data before loading + --file Path to JSON file (default: visit_data.json in project root) +""" + +import json +from datetime import datetime +from pathlib import Path + +from django.core.management.base import BaseCommand +from django.utils import timezone + + +class Command(BaseCommand): + help = "Load visit_data.json into test tables for HIS integration testing" + + def add_arguments(self, parser): + parser.add_argument( + "--clear", + action="store_true", + help="Clear existing test data before loading", + ) + parser.add_argument( + "--file", + type=str, + default="visit_data.json", + help="Path to JSON file (default: visit_data.json in project root)", + ) + + def _parse_date(self, date_str): + if not date_str: + return None + try: + naive = datetime.strptime(date_str, "%d-%b-%Y %H:%M") + return timezone.make_aware(naive) + except ValueError: + return None + + def handle(self, *args, **options): + clear = options["clear"] + file_path = options["file"] + + json_path = Path(file_path) + if not json_path.is_absolute(): + json_path = Path.cwd() / json_path + + if not json_path.exists(): + self.stderr.write(self.style.ERROR(f"File not found: {json_path}")) + return + + self.stdout.write(self.style.MIGRATE_HEADING("=" * 60)) + self.stdout.write(self.style.MIGRATE_HEADING("Load HIS Test Data")) + self.stdout.write(self.style.MIGRATE_HEADING("=" * 60)) + + self.stdout.write(f"Reading {json_path}...") + with open(json_path, "r") as f: + data = json.load(f) + + patient_list = data.get("FetchPatientDataTimeStampList", []) + ed_visits = data.get("FetchPatientDataTimeStampVisitEDDataList", []) + ip_visits = data.get("FetchPatientDataTimeStampVisitIPDataList", []) + op_visits = data.get("FetchPatientDataTimeStampVisitOPDataList", []) + + self.stdout.write(f" Patients: {len(patient_list)}") + self.stdout.write(f" ED visits: {len(ed_visits)}") + self.stdout.write(f" IP visits: {len(ip_visits)}") + self.stdout.write(f" OP visits: {len(op_visits)}") + + if clear: + from apps.integrations.models import HISTestPatient, HISTestVisit + + pv, _ = HISTestPatient.objects.all().delete() + vv, _ = HISTestVisit.objects.all().delete() + self.stdout.write(f"Cleared {pv} patients, {vv} visits") + + self._load_patients(patient_list) + self._load_visits(ed_visits, "ED") + self._load_visits(ip_visits, "IP") + self._load_visits(op_visits, "OP") + + self._ensure_test_config() + + from apps.integrations.models import HISTestPatient, HISTestVisit + + self.stdout.write(self.style.MIGRATE_HEADING("\nSummary")) + self.stdout.write(f" Patients in DB: {HISTestPatient.objects.count()}") + self.stdout.write(f" Visits in DB: {HISTestVisit.objects.count()}") + self.stdout.write(self.style.SUCCESS("\nDone!")) + + def _ensure_test_config(self): + from apps.integrations.models import IntegrationConfig, SourceSystem + + name = "HIS Test" + config, created = IntegrationConfig.objects.get_or_create( + name=name, + source_system=SourceSystem.HIS, + defaults={ + "api_url": "http://127.0.0.1:8000/api/integrations/test-his-data/", + "is_active": False, + "description": "Local test endpoint for HIS integration testing. Enable before using fetch_his_surveys.", + }, + ) + if created: + self.stdout.write(self.style.SUCCESS(f"\nCreated IntegrationConfig '{name}' (disabled by default)")) + else: + self.stdout.write(f"\nIntegrationConfig '{name}' already exists") + + self.stdout.write( + self.style.WARNING( + f"\nTo use: enable '{name}' config, then:\n" + f' python manage.py fetch_his_surveys --config "{name}" ' + f'--from-date "01-Jan-2026 00:00:00" --to-date "01-Jan-2026 02:00:00"' + ) + ) + + def _load_patients(self, patient_list): + from apps.integrations.models import HISTestPatient + + self.stdout.write(f"\nLoading {len(patient_list)} patients...") + batch = [] + skipped = 0 + existing_ids = set(HISTestPatient.objects.values_list("admission_id", flat=True)) + + for p in patient_list: + admission_id = p.get("AdmissionID", "") + if not admission_id or admission_id in existing_ids: + skipped += 1 + continue + + batch.append( + HISTestPatient( + admission_id=admission_id, + patient_id=str(p.get("PatientID", "")), + patient_type=p.get("PatientType", ""), + reg_code=p.get("RegCode", ""), + ssn=p.get("SSN", ""), + mobile_no=p.get("MobileNo", ""), + admit_date=self._parse_date(p.get("AdmitDate")), + discharge_date=self._parse_date(p.get("DischargeDate")), + patient_data=p, + hospital_id=str(p.get("HospitalID", "")), + hospital_name=p.get("HospitalName", ""), + patient_name=p.get("PatientName", ""), + ) + ) + + if len(batch) >= 1000: + HISTestPatient.objects.bulk_create(batch, batch_size=1000) + self.stdout.write(f" ...saved {len(batch)} patients") + batch = [] + + if batch: + HISTestPatient.objects.bulk_create(batch, batch_size=1000) + self.stdout.write(f" ...saved {len(batch)} patients") + + if skipped: + self.stdout.write(f" Skipped {skipped} duplicate admission_ids") + + def _load_visits(self, visit_list, category): + from apps.integrations.models import HISTestVisit + + self.stdout.write(f"\nLoading {len(visit_list)} {category} visits...") + batch = [] + + for v in visit_list: + batch.append( + HISTestVisit( + admission_id=str(v.get("AdmissionID", "")), + patient_id=str(v.get("PatientID", "")), + visit_category=category, + event_type=v.get("Type", ""), + bill_date=self._parse_date(v.get("BillDate")), + reg_code=v.get("RegCode", ""), + ssn=v.get("SSN", ""), + mobile_no=v.get("MobileNo", ""), + visit_data=v, + ) + ) + + if len(batch) >= 2000: + HISTestVisit.objects.bulk_create(batch, batch_size=2000) + self.stdout.write(f" ...saved {len(batch)} {category} visits") + batch = [] + + if batch: + HISTestVisit.objects.bulk_create(batch, batch_size=2000) + self.stdout.write(f" ...saved {len(batch)} {category} visits") diff --git a/apps/integrations/management/commands/seed_his_today_data.py b/apps/integrations/management/commands/seed_his_today_data.py new file mode 100644 index 0000000..bf737ed --- /dev/null +++ b/apps/integrations/management/commands/seed_his_today_data.py @@ -0,0 +1,211 @@ +""" +Seed today's test data for HIS integration cron testing. + +Creates ~20 patients with recent timestamps so the 5-minute +fetch_his_surveys cron will pick them up. + +Usage: + python manage.py seed_his_today_data [--clear] +""" + +import copy +import random +import string + +from django.core.management.base import BaseCommand +from django.utils import timezone + + +SEED_PREFIX = "SEED" + + +ED_EVENTS = [ + ("Consultation", 0), + ("Doctor assignment", 5), + ("Drug Prescription", 10), + ("End Of the Episode", 15), +] + +IP_EVENTS = [ + ("IP Admissions", 0), + ("Bed Allocation", 2), + ("Fit for discharge", 60), + ("Discharge date", 65), + ("Discharge followUp", 63), + ("IP Bill", 67), +] + +OP_EVENTS = [ + ("Consultation", 0), + ("Doctor Visited", 3), + ("Rad Prescription", 8), + ("Radiology Bill", 10), + ("Radiology Token", 10), + ("Radiology Patient Arrived", 15), + ("Radiology Examination completed", 20), +] + +PATIENT_SCENARIOS = [ + {"type": "ED", "discharged": True, "admit_min_ago": 90, "discharge_min_ago": 75}, + {"type": "ED", "discharged": True, "admit_min_ago": 60, "discharge_min_ago": 45}, + {"type": "ED", "discharged": True, "admit_min_ago": 30, "discharge_min_ago": 15}, + {"type": "ED", "discharged": True, "admit_min_ago": 15, "discharge_min_ago": 10}, + {"type": "ED", "discharged": False, "admit_min_ago": 20, "discharge_min_ago": None}, + {"type": "ED", "discharged": False, "admit_min_ago": 8, "discharge_min_ago": None}, + {"type": "IP", "discharged": True, "admit_min_ago": 120, "discharge_min_ago": 60}, + {"type": "IP", "discharged": True, "admit_min_ago": 90, "discharge_min_ago": 30}, + {"type": "IP", "discharged": True, "admit_min_ago": 60, "discharge_min_ago": 10}, + {"type": "IP", "discharged": True, "admit_min_ago": 45, "discharge_min_ago": 5}, + {"type": "IP", "discharged": False, "admit_min_ago": 90, "discharge_min_ago": None}, + {"type": "IP", "discharged": False, "admit_min_ago": 30, "discharge_min_ago": None}, + {"type": "OP", "discharged": True, "admit_min_ago": 200, "discharge_min_ago": None, "last_event_min_ago": 180}, + {"type": "OP", "discharged": True, "admit_min_ago": 150, "discharge_min_ago": None, "last_event_min_ago": 120}, + {"type": "OP", "discharged": True, "admit_min_ago": 120, "discharge_min_ago": None, "last_event_min_ago": 90}, + {"type": "OP", "discharged": False, "admit_min_ago": 30, "discharge_min_ago": None, "last_event_min_ago": 10}, + {"type": "OP", "discharged": False, "admit_min_ago": 15, "discharge_min_ago": None, "last_event_min_ago": 5}, + {"type": "OP", "discharged": False, "admit_min_ago": 8, "discharge_min_ago": None, "last_event_min_ago": 3}, +] + + +def _generate_admission_id(index): + suffix = "".join(random.choices(string.digits, k=6)) + return f"{SEED_PREFIX}-{index}-{suffix}" + + +def _format_his_date(dt): + return dt.strftime("%d-%b-%Y %H:%M") + + +class Command(BaseCommand): + help = "Seed today's test data for HIS cron testing" + + def add_arguments(self, parser): + parser.add_argument("--clear", action="store_true", help="Remove previously seeded data") + + def handle(self, *args, **options): + from apps.integrations.models import HISTestPatient, HISTestVisit + + if options["clear"]: + deleted_p, _ = HISTestPatient.objects.filter(admission_id__startswith=SEED_PREFIX).delete() + deleted_v, _ = HISTestVisit.objects.filter(admission_id__startswith=SEED_PREFIX).delete() + self.stdout.write(f"Cleared {deleted_p} patients, {deleted_v} visits") + return + + self.stdout.write(self.style.MIGRATE_HEADING("=" * 60)) + self.stdout.write(self.style.MIGRATE_HEADING("Seed Today's HIS Test Data")) + self.stdout.write(self.style.MIGRATE_HEADING("=" * 60)) + + now = timezone.now() + + existing = list(HISTestPatient.objects.exclude(admission_id__startswith=SEED_PREFIX).order_by("?")[:18]) + + if len(existing) < 18: + self.stderr.write(self.style.ERROR("Not enough source patients. Run load_his_test_data first.")) + return + + self.stdout.write(f"Source patients available: {len(existing)}") + + patients_batch = [] + visits_batch = [] + summary = {"ED": 0, "IP": 0, "OP": 0} + + for i, scenario in enumerate(PATIENT_SCENARIOS): + source = existing[i % len(existing)] + pt_type = scenario["type"] + summary[pt_type] = summary.get(pt_type, 0) + 1 + + admit_date = now - timezone.timedelta(minutes=scenario["admit_min_ago"]) + admission_id = _generate_admission_id(i) + + patient_data = copy.deepcopy(source.patient_data) + patient_data["AdmissionID"] = admission_id + patient_data["AdmitDate"] = _format_his_date(admit_date) + patient_data["PatientType"] = pt_type + patient_data["PatientTypeID"] = {"ED": "3", "IP": "2", "OP": "1"}[pt_type] + + if scenario["discharged"] and scenario.get("discharge_min_ago"): + discharge_date = now - timezone.timedelta(minutes=scenario["discharge_min_ago"]) + patient_data["DischargeDate"] = _format_his_date(discharge_date) + else: + discharge_date = None + patient_data["DischargeDate"] = None + + patients_batch.append( + HISTestPatient( + admission_id=admission_id, + patient_id=source.patient_id, + patient_type=pt_type, + reg_code=source.reg_code, + ssn=source.ssn, + mobile_no=source.mobile_no, + admit_date=admit_date, + discharge_date=discharge_date, + patient_data=patient_data, + hospital_id=source.hospital_id, + hospital_name=source.hospital_name, + patient_name=source.patient_name, + ) + ) + + visit_category = {"ED": "ED", "IP": "IP", "OP": "OP"}[pt_type] + patient_type_visit = {"ED": "ER", "IP": "IP", "OP": "OP"}[pt_type] + events = {"ED": ED_EVENTS, "IP": IP_EVENTS, "OP": OP_EVENTS}[pt_type] + + last_event_min_ago = scenario.get("last_event_min_ago") + if last_event_min_ago is None: + if scenario["discharged"] and scenario.get("discharge_min_ago"): + last_event_min_ago = scenario["discharge_min_ago"] + else: + last_event_min_ago = max(scenario["admit_min_ago"] - 5, 1) + + for j, (event_name, offset_min) in enumerate(events): + if not scenario["discharged"] and event_name in ( + "Fit for discharge", + "Discharge date", + "Discharge followUp", + "IP Bill", + "End Of the Episode", + ): + continue + + if pt_type == "OP" and not scenario["discharged"] and j == len(events) - 1: + event_date = now - timezone.timedelta(minutes=last_event_min_ago) + else: + event_date = admit_date + timezone.timedelta(minutes=offset_min) + + visit_data = { + "PatientType": patient_type_visit, + "Type": event_name, + "BillDate": _format_his_date(event_date), + "AdmissionID": admission_id, + "PatientID": source.patient_id, + "RegCode": source.reg_code, + "SSN": source.ssn, + "MobileNo": source.mobile_no, + } + + visits_batch.append( + HISTestVisit( + admission_id=admission_id, + patient_id=source.patient_id, + visit_category=visit_category, + event_type=event_name, + bill_date=event_date, + reg_code=source.reg_code, + ssn=source.ssn, + mobile_no=source.mobile_no, + visit_data=visit_data, + ) + ) + + HISTestPatient.objects.bulk_create(patients_batch, batch_size=1000) + self.stdout.write(f"Created {len(patients_batch)} patients") + + HISTestVisit.objects.bulk_create(visits_batch, batch_size=2000) + self.stdout.write(f"Created {len(visits_batch)} visits") + + self.stdout.write(self.style.MIGRATE_HEADING("\nSummary")) + self.stdout.write(f" ED: {summary['ED']} (4 discharged, 2 active)") + self.stdout.write(f" IP: {summary['IP']} (4 discharged, 2 active)") + self.stdout.write(f" OP: {summary['OP']} (3 complete, 3 in progress)") + self.stdout.write(self.style.SUCCESS("\nDone! Run fetch_his_surveys to test.")) diff --git a/apps/integrations/management/commands/seed_survey_mappings.py b/apps/integrations/management/commands/seed_survey_mappings.py index 43f6eea..907d926 100644 --- a/apps/integrations/management/commands/seed_survey_mappings.py +++ b/apps/integrations/management/commands/seed_survey_mappings.py @@ -7,157 +7,124 @@ from django.db import transaction class Command(BaseCommand): - help = 'Seed survey template mappings for HIS integration' + help = "Seed survey template mappings for HIS integration" - SATISFACTION_OPTIONS = [ - 'Very Unsatisfied', - 'Poor', - 'Neutral', - 'Good', - 'Very Satisfied' - ] + SATISFACTION_OPTIONS = ["Very Unsatisfied", "Poor", "Neutral", "Good", "Very Satisfied"] def handle(self, *args, **options): - self.stdout.write(self.style.SUCCESS('Seeding survey template mappings...')) + self.stdout.write(self.style.SUCCESS("Seeding survey template mappings...")) # Get or create satisfaction surveys inpatient_survey = self.get_or_create_satisfaction_survey( - 'Inpatient Satisfaction Survey', - 'How satisfied were you with your inpatient stay?' + "Inpatient Satisfaction Survey", "How satisfied were you with your inpatient stay?" ) - + outpatient_survey = self.get_or_create_satisfaction_survey( - 'Outpatient Satisfaction Survey', - 'How satisfied were you with your outpatient visit?' + "Outpatient Satisfaction Survey", "How satisfied were you with your outpatient visit?" ) - + appointment_survey = self.get_or_create_satisfaction_survey( - 'Appointment Satisfaction Survey', - 'How satisfied were you with your appointment?' + "Appointment Satisfaction Survey", "How satisfied were you with your appointment?" ) # Create mappings for patient types - self.create_or_update_mapping('INPATIENT', inpatient_survey, 'Inpatient') - self.create_or_update_mapping('OUTPATIENT', outpatient_survey, 'Outpatient') - self.create_or_update_mapping('APPOINTMENT', appointment_survey, 'Appointment') + self.create_or_update_mapping("IP", inpatient_survey, "Inpatient") + self.create_or_update_mapping("OP", outpatient_survey, "Outpatient") + self.create_or_update_mapping("ED", appointment_survey, "Emergency") - self.stdout.write(self.style.SUCCESS('Survey template mappings seeded successfully!')) - self.stdout.write('\nSurvey Templates:') - self.stdout.write(f' - Inpatient: {inpatient_survey.name} (ID: {inpatient_survey.id})') - self.stdout.write(f' - Outpatient: {outpatient_survey.name} (ID: {outpatient_survey.id})') - self.stdout.write(f' - Appointment: {appointment_survey.name} (ID: {appointment_survey.id})') + self.stdout.write(self.style.SUCCESS("Survey template mappings seeded successfully!")) + self.stdout.write("\nSurvey Templates:") + self.stdout.write(f" - Inpatient: {inpatient_survey.name} (ID: {inpatient_survey.id})") + self.stdout.write(f" - Outpatient: {outpatient_survey.name} (ID: {outpatient_survey.id})") + self.stdout.write(f" - Appointment: {appointment_survey.name} (ID: {appointment_survey.id})") def get_or_create_satisfaction_survey(self, name, question_text): """Get or create a satisfaction survey with multiple choice question""" survey = SurveyTemplate.objects.filter(name=name).first() - + if not survey: - self.stdout.write(f'Creating survey: {name}') + self.stdout.write(f"Creating survey: {name}") # Get first hospital (default) hospital = Hospital.objects.first() if not hospital: - self.stdout.write(self.style.ERROR('No hospital found! Please create a hospital first.')) + self.stdout.write(self.style.ERROR("No hospital found! Please create a hospital first.")) return None - + survey = SurveyTemplate.objects.create( - name=name, - name_ar=name, - hospital=hospital, - survey_type='general', - is_active=True + name=name, name_ar=name, hospital=hospital, survey_type="general", is_active=True ) - + # Create the satisfaction question with choices choices = [] for idx, option_text in enumerate(self.SATISFACTION_OPTIONS, 1): - choices.append({ - 'value': str(idx), - 'label': option_text, - 'label_ar': option_text - }) - + choices.append({"value": str(idx), "label": option_text, "label_ar": option_text}) + question = SurveyQuestion.objects.create( survey_template=survey, text=question_text, - question_type='multiple_choice', + question_type="multiple_choice", order=1, is_required=True, - choices_json=choices + choices_json=choices, ) - - self.stdout.write(f' Created question: {question_text}') - self.stdout.write(f' Added {len(self.SATISFACTION_OPTIONS)} satisfaction options') + + self.stdout.write(f" Created question: {question_text}") + self.stdout.write(f" Added {len(self.SATISFACTION_OPTIONS)} satisfaction options") else: # Ensure the question has correct options self.update_satisfaction_question(survey) - self.stdout.write(f'Found existing survey: {name}') - + self.stdout.write(f"Found existing survey: {name}") + return survey def update_satisfaction_question(self, survey): """Update survey question to ensure it has correct satisfaction options""" - question = survey.questions.filter( - question_type='multiple_choice' - ).first() - + question = survey.questions.filter(question_type="multiple_choice").first() + if not question: - self.stdout.write(f' Warning: No multiple choice question found in {survey.name}') + self.stdout.write(f" Warning: No multiple choice question found in {survey.name}") return - + # Check if all options exist existing_choices = question.choices_json or [] - existing_labels = {choice['label'] for choice in existing_choices} + existing_labels = {choice["label"] for choice in existing_choices} required_options = set(self.SATISFACTION_OPTIONS) - + if existing_labels == required_options: - self.stdout.write(f' Question has correct satisfaction options') + self.stdout.write(f" Question has correct satisfaction options") return - + # Rebuild choices with all required options choices = [] for idx, option_text in enumerate(self.SATISFACTION_OPTIONS, 1): # Find existing choice if it exists - existing_choice = next( - (c for c in existing_choices if c['label'] == option_text), - None - ) + existing_choice = next((c for c in existing_choices if c["label"] == option_text), None) if existing_choice: choices.append(existing_choice) else: - choices.append({ - 'value': str(idx), - 'label': option_text, - 'label_ar': option_text - }) - + choices.append({"value": str(idx), "label": option_text, "label_ar": option_text}) + question.choices_json = choices question.save() - self.stdout.write(f' Updated question with correct satisfaction options') + self.stdout.write(f" Updated question with correct satisfaction options") def create_or_update_mapping(self, patient_type, survey_template, description): """Create or update a survey template mapping""" - mapping = SurveyTemplateMapping.objects.filter( - patient_type=patient_type, - is_active=True - ).first() - + mapping = SurveyTemplateMapping.objects.filter(patient_type=patient_type, is_active=True).first() + if mapping: if mapping.survey_template != survey_template: mapping.survey_template = survey_template mapping.save() - self.stdout.write(f'Updated mapping for {description}: {survey_template.name}') + self.stdout.write(f"Updated mapping for {description}: {survey_template.name}") else: - self.stdout.write(f'Existing mapping for {description}: {survey_template.name}') + self.stdout.write(f"Existing mapping for {description}: {survey_template.name}") else: # Deactivate existing mappings for this patient type - SurveyTemplateMapping.objects.filter( - patient_type=patient_type - ).update(is_active=False) - + SurveyTemplateMapping.objects.filter(patient_type=patient_type).update(is_active=False) + # Create new active mapping mapping = SurveyTemplateMapping.objects.create( - patient_type=patient_type, - survey_template=survey_template, - is_active=True + patient_type=patient_type, survey_template=survey_template, is_active=True ) - self.stdout.write(f'Created mapping for {description}: {survey_template.name}') + self.stdout.write(f"Created mapping for {description}: {survey_template.name}") diff --git a/apps/integrations/models.py b/apps/integrations/models.py index dec21cf..24740ab 100644 --- a/apps/integrations/models.py +++ b/apps/integrations/models.py @@ -10,6 +10,7 @@ This module handles integration events from: - CHI (Council of Health Insurance) - Other external systems """ + from django.db import models from apps.core.models import BaseChoices, TimeStampedModel, UUIDModel @@ -17,35 +18,37 @@ from apps.core.models import BaseChoices, TimeStampedModel, UUIDModel class EventStatus(BaseChoices): """Event processing status""" - PENDING = 'pending', 'Pending' - PROCESSING = 'processing', 'Processing' - PROCESSED = 'processed', 'Processed' - FAILED = 'failed', 'Failed' - IGNORED = 'ignored', 'Ignored' + + PENDING = "pending", "Pending" + PROCESSING = "processing", "Processing" + PROCESSED = "processed", "Processed" + FAILED = "failed", "Failed" + IGNORED = "ignored", "Ignored" class SourceSystem(BaseChoices): """Source system choices""" - HIS = 'his', 'Hospital Information System' - LAB = 'lab', 'Laboratory System' - RADIOLOGY = 'radiology', 'Radiology System' - PHARMACY = 'pharmacy', 'Pharmacy System' - MOH = 'moh', 'Ministry of Health' - CHI = 'chi', 'Council of Health Insurance' - PXCONNECT = 'pxconnect', 'PX Connect' - OTHER = 'other', 'Other' + + HIS = "his", "Hospital Information System" + LAB = "lab", "Laboratory System" + RADIOLOGY = "radiology", "Radiology System" + PHARMACY = "pharmacy", "Pharmacy System" + MOH = "moh", "Ministry of Health" + CHI = "chi", "Council of Health Insurance" + PXCONNECT = "pxconnect", "PX Connect" + OTHER = "other", "Other" class InboundEvent(UUIDModel, TimeStampedModel): """ Inbound integration event from external systems. - + Events trigger journey stage completions. For example: - Event code: "OPD_VISIT_COMPLETED" → completes "MD Consultation" stage - Event code: "LAB_ORDER_COMPLETED" → completes "Lab" stage - Event code: "RADIOLOGY_REPORT_FINALIZED" → completes "Radiology" stage - Event code: "PHARMACY_DISPENSED" → completes "Pharmacy" stage - + Processing flow: 1. Event received via API (POST /api/integrations/events/) 2. Stored with status=PENDING @@ -56,304 +59,463 @@ class InboundEvent(UUIDModel, TimeStampedModel): d. Create survey instance if configured e. Update event status to PROCESSED """ - + # Source information source_system = models.CharField( - max_length=50, - choices=SourceSystem.choices, - db_index=True, - help_text="System that sent this event" + max_length=50, choices=SourceSystem.choices, db_index=True, help_text="System that sent this event" ) event_code = models.CharField( - max_length=100, - db_index=True, - help_text="Event type code (e.g., OPD_VISIT_COMPLETED, LAB_ORDER_COMPLETED)" + max_length=100, db_index=True, help_text="Event type code (e.g., OPD_VISIT_COMPLETED, LAB_ORDER_COMPLETED)" ) - + # Identifiers - encounter_id = models.CharField( - max_length=100, - db_index=True, - help_text="Encounter ID from HIS system" - ) + encounter_id = models.CharField(max_length=100, db_index=True, help_text="Encounter ID from HIS system") patient_identifier = models.CharField( - max_length=100, - blank=True, - db_index=True, - help_text="Patient MRN or other identifier" + max_length=100, blank=True, db_index=True, help_text="Patient MRN or other identifier" ) - + # Event data - payload_json = models.JSONField( - help_text="Full event payload from source system" - ) - + payload_json = models.JSONField(help_text="Full event payload from source system") + # Processing status - status = models.CharField( - max_length=20, - choices=EventStatus.choices, - default=EventStatus.PENDING, - db_index=True - ) - + status = models.CharField(max_length=20, choices=EventStatus.choices, default=EventStatus.PENDING, db_index=True) + # Timestamps received_at = models.DateTimeField(auto_now_add=True, db_index=True) processed_at = models.DateTimeField(null=True, blank=True) - + # Processing results - error = models.TextField( - blank=True, - help_text="Error message if processing failed" - ) - processing_attempts = models.IntegerField( - default=0, - help_text="Number of processing attempts" - ) - + error = models.TextField(blank=True, help_text="Error message if processing failed") + processing_attempts = models.IntegerField(default=0, help_text="Number of processing attempts") + # Extracted context (from payload) - physician_license = models.CharField( - max_length=100, - blank=True, - help_text="Physician license number from event" - ) - department_code = models.CharField( - max_length=50, - blank=True, - help_text="Department code from event" - ) - + physician_license = models.CharField(max_length=100, blank=True, help_text="Physician license number from event") + department_code = models.CharField(max_length=50, blank=True, help_text="Department code from event") + # Metadata - metadata = models.JSONField( - default=dict, - blank=True, - help_text="Additional processing metadata" - ) - + metadata = models.JSONField(default=dict, blank=True, help_text="Additional processing metadata") + class Meta: - ordering = ['-received_at'] + ordering = ["-received_at"] indexes = [ - models.Index(fields=['status', '-received_at']), - models.Index(fields=['encounter_id', 'event_code']), - models.Index(fields=['source_system', '-received_at']), + models.Index(fields=["status", "-received_at"]), + models.Index(fields=["encounter_id", "event_code"]), + models.Index(fields=["source_system", "-received_at"]), ] - + def __str__(self): return f"{self.source_system} - {self.event_code} - {self.encounter_id} ({self.status})" - + def mark_processing(self): """Mark event as being processed""" self.status = EventStatus.PROCESSING self.processing_attempts += 1 - self.save(update_fields=['status', 'processing_attempts']) - + self.save(update_fields=["status", "processing_attempts"]) + def mark_processed(self): """Mark event as successfully processed""" from django.utils import timezone + self.status = EventStatus.PROCESSED self.processed_at = timezone.now() - self.save(update_fields=['status', 'processed_at']) - + self.save(update_fields=["status", "processed_at"]) + def mark_failed(self, error_message): """Mark event as failed with error message""" self.status = EventStatus.FAILED self.error = error_message - self.save(update_fields=['status', 'error']) - + self.save(update_fields=["status", "error"]) + def mark_ignored(self, reason): """Mark event as ignored (e.g., no matching journey)""" self.status = EventStatus.IGNORED self.error = reason - self.save(update_fields=['status', 'error']) + self.save(update_fields=["status", "error"]) class IntegrationConfig(UUIDModel, TimeStampedModel): """ Configuration for external system integrations. - + Stores API endpoints, credentials, and mapping rules. """ + name = models.CharField(max_length=200, unique=True) - source_system = models.CharField( - max_length=50, - choices=SourceSystem.choices, - unique=True - ) - + source_system = models.CharField(max_length=50, choices=SourceSystem.choices, unique=True) + # Connection details api_url = models.URLField(blank=True, help_text="API endpoint URL") api_key = models.CharField(max_length=500, blank=True, help_text="API key (encrypted)") - + # Configuration is_active = models.BooleanField(default=True) config_json = models.JSONField( - default=dict, - blank=True, - help_text="Additional configuration (event mappings, field mappings, etc.)" + default=dict, blank=True, help_text="Additional configuration (event mappings, field mappings, etc.)" ) - + # Metadata description = models.TextField(blank=True) last_sync_at = models.DateTimeField(null=True, blank=True) - + class Meta: - ordering = ['name'] - + ordering = ["name"] + def __str__(self): return f"{self.name} ({self.source_system})" class PatientType(BaseChoices): """HIS Patient Type codes""" - INPATIENT = '1', 'Inpatient (Type 1)' - OPD = '2', 'Outpatient (Type 2)' - EMS = '3', 'Emergency (Type 3)' - DAYCASE = '4', 'Day Case (Type 4)' - APPOINTMENT = 'APPOINTMENT', 'Appointment' + + INPATIENT = "IP", "Inpatient" + OPD = "OP", "Outpatient" + EMS = "ED", "Emergency" + DAYCASE = "DAYCASE", "Day Case" + APPOINTMENT = "APPOINTMENT", "Appointment" class SurveyTemplateMapping(UUIDModel, TimeStampedModel): """ Maps patient types to survey templates for automatic survey delivery. - + This replaces the search-based template selection with explicit mappings. Allows administrators to control which survey template is sent for each patient type and hospital. - + Example: - PatientType: "1" (Inpatient) → Inpatient Satisfaction Survey - PatientType: "2" (OPD) → Outpatient Satisfaction Survey - PatientType: "APPOINTMENT" → Appointment Satisfaction Survey """ - + # Mapping key patient_type = models.CharField( - max_length=20, - choices=PatientType.choices, - db_index=True, - help_text="Patient type from HIS system" + max_length=20, choices=PatientType.choices, db_index=True, help_text="Patient type from HIS system" ) - + # Target survey survey_template = models.ForeignKey( - 'surveys.SurveyTemplate', + "surveys.SurveyTemplate", on_delete=models.CASCADE, - related_name='patient_type_mappings', - help_text="Survey template to send for this patient type" + related_name="patient_type_mappings", + help_text="Survey template to send for this patient type", ) - + # Hospital specificity (null = global mapping) hospital = models.ForeignKey( - 'organizations.Hospital', + "organizations.Hospital", on_delete=models.CASCADE, - related_name='survey_template_mappings', + related_name="survey_template_mappings", null=True, blank=True, - help_text="Hospital for this mapping (null = applies to all hospitals)" + help_text="Hospital for this mapping (null = applies to all hospitals)", ) - + # Activation - is_active = models.BooleanField( - default=True, - db_index=True, - help_text="Whether this mapping is active" - ) + is_active = models.BooleanField(default=True, db_index=True, help_text="Whether this mapping is active") # Delay configuration - send_delay_hours = models.IntegerField( - default=1, - help_text="Hours after discharge to send survey" - ) + send_delay_hours = models.IntegerField(default=1, help_text="Hours after discharge to send survey") class Meta: - ordering = ['hospital', 'patient_type'] + ordering = ["hospital", "patient_type"] indexes = [ - models.Index(fields=['patient_type', 'hospital', 'is_active']), + models.Index(fields=["patient_type", "hospital", "is_active"]), ] # Ensure only one active mapping per patient type per hospital constraints = [ models.UniqueConstraint( - fields=['patient_type', 'hospital'], + fields=["patient_type", "hospital"], condition=models.Q(is_active=True), - name='unique_active_mapping_per_type_hospital' + name="unique_active_mapping_per_type_hospital", ) ] - + def __str__(self): - hospital_name = self.hospital.name if self.hospital else 'All Hospitals' - status = 'Active' if self.is_active else 'Inactive' - return f"{self.get_patient_type_display()} → {self.survey_template.name} ({hospital_name}) [{status}]" - + hospital_name = self.hospital.name if self.hospital else "All Hospitals" + status = "Active" if self.is_active else "Inactive" + return f"{self.get_patient_type_display()} -> {self.survey_template.name} ({hospital_name}) [{status}]" + @staticmethod def get_template_for_patient_type(patient_type: str, hospital): """ Get the active survey template for a patient type and hospital. - + Search order: 1. Hospital-specific active mapping 2. Global active mapping (hospital is null) - + Args: patient_type: HIS PatientType code (e.g., "1", "2", "APPOINTMENT") hospital: Hospital instance - + Returns: SurveyTemplate or None if no active mapping found """ # Try hospital-specific mapping first mapping = SurveyTemplateMapping.objects.filter( - patient_type=patient_type, - hospital=hospital, - is_active=True + patient_type=patient_type, hospital=hospital, is_active=True ).first() - + if mapping: return mapping.survey_template - + # Fall back to global mapping mapping = SurveyTemplateMapping.objects.filter( - patient_type=patient_type, - hospital__isnull=True, - is_active=True + patient_type=patient_type, hospital__isnull=True, is_active=True ).first() - + return mapping.survey_template if mapping else None +class HISPatientVisit(UUIDModel, TimeStampedModel): + """ + Stores patient visit data fetched from HIS system. + + Decoupled from survey creation - patient and visit data are saved + on every fetch regardless of whether the visit is complete. + Survey is created and linked only when the visit is complete. + """ + + patient = models.ForeignKey( + "organizations.Patient", + on_delete=models.CASCADE, + related_name="his_visits", + null=True, + blank=True, + ) + hospital = models.ForeignKey( + "organizations.Hospital", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="his_visits", + ) + admission_id = models.CharField(max_length=100, db_index=True) + reg_code = models.CharField(max_length=100, blank=True, db_index=True) + patient_id_his = models.CharField(max_length=100, blank=True, db_index=True, help_text="PatientID from HIS") + patient_type = models.CharField(max_length=10, help_text="ED, IP, OP from HIS") + admit_date = models.DateTimeField(null=True, blank=True) + discharge_date = models.DateTimeField(null=True, blank=True, help_text="From HIS DischargeDate (null for OP)") + effective_discharge_date = models.DateTimeField( + null=True, blank=True, help_text="For OP: last visit timestamp when deemed complete" + ) + visit_data = models.JSONField(default=dict, blank=True, help_text="Full patient demographic dict from HIS") + visit_timeline = models.JSONField(default=list, blank=True, help_text="Extracted visit events for this patient") + + primary_doctor = models.CharField( + max_length=300, blank=True, help_text="From HIS PrimaryDoctor (raw text fallback)" + ) + primary_doctor_fk = models.ForeignKey( + "organizations.Staff", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="his_visits_as_doctor", + help_text="Resolved Staff record from PrimaryDoctor ID prefix", + ) + consultant_id = models.CharField(max_length=50, blank=True, help_text="From HIS ConsultantID (raw text fallback)") + consultant_fk = models.ForeignKey( + "organizations.Staff", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="his_visits_as_consultant", + help_text="Resolved Staff record from ConsultantID", + ) + company_name = models.CharField(max_length=300, blank=True, help_text="From HIS CompanyName (insurance sponsor)") + grade_name = models.CharField(max_length=100, blank=True, help_text="From HIS GradeName (insurance grade)") + insurance_company_name = models.CharField(max_length=300, blank=True, help_text="From HIS InsuranceCompanyName") + bill_type = models.CharField(max_length=20, blank=True, help_text="From HIS BillType (CS/CR)") + is_vip = models.BooleanField(default=False, db_index=True, help_text="From HIS IsVIP") + nationality = models.CharField(max_length=100, blank=True, help_text="From HIS PatientNationality") + + is_visit_complete = models.BooleanField(default=False, db_index=True) + survey_instance = models.ForeignKey( + "surveys.SurveyInstance", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="his_visit", + ) + last_his_fetch_at = models.DateTimeField( + null=True, blank=True, help_text="Last time this visit was seen in a HIS fetch" + ) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["patient_type", "is_visit_complete"]), + models.Index(fields=["patient_type", "last_his_fetch_at"]), + models.Index(fields=["admission_id", "is_visit_complete"]), + ] + constraints = [models.UniqueConstraint(fields=["admission_id"], name="unique_his_visit_per_admission")] + + def __str__(self): + name = self.patient.get_full_name() if self.patient else self.patient_id_his + return f"{name} ({self.patient_type}) - {self.admission_id}" + + @property + def doctor_display(self): + if self.primary_doctor_fk: + return str(self.primary_doctor_fk) + return self.primary_doctor or "-" + + @property + def consultant_display(self): + if self.consultant_fk: + return str(self.consultant_fk) + return self.consultant_id or "-" + + +class HISVisitEvent(UUIDModel, TimeStampedModel): + """ + Individual visit event from HIS timeline. + + Extracted from visit_timeline JSON for better querying and filtering. + Each event represents a single touchpoint in the patient journey. + """ + + visit = models.ForeignKey( + HISPatientVisit, + on_delete=models.CASCADE, + related_name="visit_events", + ) + event_type = models.CharField(max_length=200, blank=True, help_text="Type from HIS (e.g., 'Registration')") + bill_date = models.CharField(max_length=50, blank=True, help_text="Raw BillDate from HIS (DD-Mon-YYYY HH:MM)") + parsed_date = models.DateTimeField(null=True, blank=True, db_index=True, help_text="Parsed bill_date") + patient_type = models.CharField(max_length=10, blank=True, help_text="PatientType for this event") + visit_category = models.CharField(max_length=10, blank=True, help_text="Visit category: ED, IP, OP") + admission_id = models.CharField(max_length=100, blank=True, db_index=True) + patient_id = models.CharField(max_length=100, blank=True, db_index=True, help_text="PatientID from HIS") + reg_code = models.CharField(max_length=100, blank=True, db_index=True) + ssn = models.CharField(max_length=50, blank=True, db_index=True) + mobile_no = models.CharField(max_length=50, blank=True) + + class Meta: + ordering = ["parsed_date"] + indexes = [ + models.Index(fields=["visit", "parsed_date"]), + models.Index(fields=["visit_category", "parsed_date"]), + ] + + def __str__(self): + return f"{self.event_type} ({self.visit_category}) - {self.admission_id}" + + +class HISEventType(UUIDModel, TimeStampedModel): + """ + Unique event types extracted from HIS data. + + Auto-populated when HISPatientVisit events are synced. + Used to populate the event type dropdown in question configuration. + """ + + event_type = models.CharField( + max_length=200, + unique=True, + db_index=True, + help_text="HIS event type name (e.g., 'Lab Bill', 'Triage')", + ) + patient_types = models.JSONField( + default=list, + blank=True, + help_text="Patient types that have this event: ['OP', 'IP', 'ED']", + ) + event_count = models.IntegerField( + default=0, + help_text="Total number of times this event type has been seen", + ) + last_seen_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["event_type"] + verbose_name = "HIS Event Type" + verbose_name_plural = "HIS Event Types" + + def __str__(self): + types = ", ".join(self.patient_types) if self.patient_types else "N/A" + return f"{self.event_type} ({types})" + + class EventMapping(UUIDModel, TimeStampedModel): """ Maps external event codes to internal trigger codes. - + Example: - External: "VISIT_COMPLETE" → Internal: "OPD_VISIT_COMPLETED" - External: "LAB_RESULT_READY" → Internal: "LAB_ORDER_COMPLETED" """ - integration_config = models.ForeignKey( - IntegrationConfig, - on_delete=models.CASCADE, - related_name='event_mappings' - ) - - external_event_code = models.CharField( - max_length=100, - help_text="Event code from external system" - ) - internal_event_code = models.CharField( - max_length=100, - help_text="Internal event code used in journey stages" - ) - + + integration_config = models.ForeignKey(IntegrationConfig, on_delete=models.CASCADE, related_name="event_mappings") + + external_event_code = models.CharField(max_length=100, help_text="Event code from external system") + internal_event_code = models.CharField(max_length=100, help_text="Internal event code used in journey stages") + # Field mappings field_mappings = models.JSONField( - default=dict, - blank=True, - help_text="Maps external field names to internal field names" + default=dict, blank=True, help_text="Maps external field names to internal field names" ) - + is_active = models.BooleanField(default=True) - + class Meta: - unique_together = [['integration_config', 'external_event_code']] - ordering = ['integration_config', 'external_event_code'] - + unique_together = [["integration_config", "external_event_code"]] + ordering = ["integration_config", "external_event_code"] + def __str__(self): return f"{self.external_event_code} → {self.internal_event_code}" + + +class HISTestPatient(TimeStampedModel): + """Test patient data loaded from visit_data.json for testing HIS integration.""" + + admission_id = models.CharField(max_length=100, db_index=True) + patient_id = models.CharField(max_length=100, db_index=True) + patient_type = models.CharField(max_length=10, db_index=True) + reg_code = models.CharField(max_length=100, blank=True, db_index=True) + ssn = models.CharField(max_length=50, blank=True, db_index=True) + mobile_no = models.CharField(max_length=20, blank=True, db_index=True) + admit_date = models.DateTimeField(db_index=True) + discharge_date = models.DateTimeField(null=True, blank=True) + patient_data = models.JSONField(default=dict) + hospital_id = models.CharField(max_length=20, blank=True) + hospital_name = models.CharField(max_length=200, blank=True) + patient_name = models.CharField(max_length=300, blank=True) + + class Meta: + ordering = ["admit_date"] + indexes = [ + models.Index(fields=["patient_type", "admit_date"]), + models.Index(fields=["ssn", "admit_date"]), + models.Index(fields=["mobile_no", "admit_date"]), + ] + constraints = [models.UniqueConstraint(fields=["admission_id"], name="unique_test_patient_admission")] + + def __str__(self): + return f"{self.patient_name} ({self.patient_type}) - {self.admission_id}" + + +class HISTestVisit(TimeStampedModel): + """Test visit events loaded from visit_data.json for testing HIS integration.""" + + admission_id = models.CharField(max_length=100, db_index=True) + patient_id = models.CharField(max_length=100, db_index=True) + visit_category = models.CharField(max_length=10, db_index=True) + event_type = models.CharField(max_length=200, blank=True) + bill_date = models.DateTimeField(null=True, blank=True, db_index=True) + reg_code = models.CharField(max_length=100, blank=True) + ssn = models.CharField(max_length=50, blank=True, db_index=True) + mobile_no = models.CharField(max_length=20, blank=True, db_index=True) + visit_data = models.JSONField(default=dict) + + class Meta: + ordering = ["bill_date"] + indexes = [ + models.Index(fields=["admission_id", "visit_category"]), + models.Index(fields=["patient_id", "visit_category"]), + models.Index(fields=["admission_id", "bill_date"]), + ] + + def __str__(self): + return f"{self.event_type} ({self.visit_category}) - {self.admission_id}" diff --git a/apps/integrations/serializers.py b/apps/integrations/serializers.py index dbe65a3..546d124 100644 --- a/apps/integrations/serializers.py +++ b/apps/integrations/serializers.py @@ -1,6 +1,7 @@ """ Integrations serializers """ + from rest_framework import serializers from .models import EventMapping, InboundEvent, IntegrationConfig, SurveyTemplateMapping @@ -8,27 +9,44 @@ from .models import EventMapping, InboundEvent, IntegrationConfig, SurveyTemplat class InboundEventSerializer(serializers.ModelSerializer): """Inbound event serializer""" - + class Meta: model = InboundEvent fields = [ - 'id', 'source_system', 'event_code', 'encounter_id', 'patient_identifier', - 'payload_json', 'status', 'received_at', 'processed_at', - 'error', 'processing_attempts', - 'physician_license', 'department_code', 'metadata', - 'created_at', 'updated_at' + "id", + "source_system", + "event_code", + "encounter_id", + "patient_identifier", + "payload_json", + "status", + "received_at", + "processed_at", + "error", + "processing_attempts", + "physician_license", + "department_code", + "metadata", + "created_at", + "updated_at", ] read_only_fields = [ - 'id', 'status', 'received_at', 'processed_at', - 'error', 'processing_attempts', 'metadata', - 'created_at', 'updated_at' + "id", + "status", + "received_at", + "processed_at", + "error", + "processing_attempts", + "metadata", + "created_at", + "updated_at", ] class InboundEventCreateSerializer(serializers.ModelSerializer): """ Serializer for creating inbound events via API. - + External systems POST to /api/integrations/events/ with: { "source_system": "his", @@ -43,98 +61,113 @@ class InboundEventCreateSerializer(serializers.ModelSerializer): } } """ - + class Meta: model = InboundEvent - fields = [ - 'source_system', 'event_code', 'encounter_id', - 'patient_identifier', 'payload_json' - ] - + fields = ["source_system", "event_code", "encounter_id", "patient_identifier", "payload_json"] + def create(self, validated_data): """ Create event and extract context from payload. Event will be processed asynchronously by Celery task. """ - payload = validated_data.get('payload_json', {}) - + payload = validated_data.get("payload_json", {}) + # Extract physician and department from payload - validated_data['physician_license'] = payload.get('physician_license', '') - validated_data['department_code'] = payload.get('department_code', '') - + validated_data["physician_license"] = payload.get("physician_license", "") + validated_data["department_code"] = payload.get("department_code", "") + event = InboundEvent.objects.create(**validated_data) - + # Queue event for processing (will be implemented in tasks.py) # from apps.integrations.tasks import process_inbound_event # process_inbound_event.delay(event.id) - + return event class InboundEventListSerializer(serializers.ModelSerializer): """Simplified inbound event serializer for list views""" - + class Meta: model = InboundEvent - fields = [ - 'id', 'source_system', 'event_code', 'encounter_id', - 'status', 'processing_attempts', 'received_at' - ] + fields = ["id", "source_system", "event_code", "encounter_id", "status", "processing_attempts", "received_at"] class EventMappingSerializer(serializers.ModelSerializer): """Event mapping serializer""" - integration_name = serializers.CharField(source='integration_config.name', read_only=True) - + + integration_name = serializers.CharField(source="integration_config.name", read_only=True) + class Meta: model = EventMapping fields = [ - 'id', 'integration_config', 'integration_name', - 'external_event_code', 'internal_event_code', - 'field_mappings', 'is_active', - 'created_at', 'updated_at' + "id", + "integration_config", + "integration_name", + "external_event_code", + "internal_event_code", + "field_mappings", + "is_active", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_at", "updated_at"] class SurveyTemplateMappingSerializer(serializers.ModelSerializer): """Survey template mapping serializer""" - hospital_name = serializers.CharField(source='hospital.name', read_only=True) - survey_template_name = serializers.CharField(source='survey_template.name', read_only=True) - patient_type_display = serializers.CharField(source='get_patient_type_display', read_only=True) - + + hospital_name = serializers.CharField(source="hospital.name", read_only=True) + survey_template_name = serializers.CharField(source="survey_template.name", read_only=True) + patient_type_display = serializers.CharField(source="get_patient_type_display", read_only=True) + class Meta: model = SurveyTemplateMapping fields = [ - 'id', 'hospital', 'hospital_name', - 'patient_type', 'patient_type_display', - 'survey_template', 'survey_template_name', - 'is_active', - 'created_at', 'updated_at' + "id", + "hospital", + "hospital_name", + "patient_type", + "patient_type_display", + "survey_template", + "survey_template_name", + "is_active", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_at", "updated_at"] class IntegrationConfigSerializer(serializers.ModelSerializer): """Integration configuration serializer""" + event_mappings = EventMappingSerializer(many=True, read_only=True) - + class Meta: model = IntegrationConfig fields = [ - 'id', 'name', 'source_system', 'description', - 'api_url', 'is_active', 'config_json', - 'event_mappings', 'last_sync_at', - 'created_at', 'updated_at' + "id", + "name", + "source_system", + "description", + "api_url", + "is_active", + "config_json", + "event_mappings", + "last_sync_at", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'last_sync_at', 'created_at', 'updated_at'] + read_only_fields = ["id", "last_sync_at", "created_at", "updated_at"] extra_kwargs = { - 'api_key': {'write_only': True} # Don't expose API key in responses + "api_key": {"write_only": True} # Don't expose API key in responses } class HISPatientDemographicSerializer(serializers.Serializer): """Serializer for HIS patient demographic data""" + Type = serializers.CharField() PatientID = serializers.CharField() AdmissionID = serializers.CharField() @@ -165,24 +198,30 @@ class HISPatientDemographicSerializer(serializers.Serializer): class HISVisitDataSerializer(serializers.Serializer): """Serializer for HIS visit/timeline data""" + Type = serializers.CharField() BillDate = serializers.CharField() + PatientType = serializers.CharField(required=False, allow_blank=True) + AdmissionID = serializers.CharField(required=False, allow_blank=True) + PatientID = serializers.CharField(required=False, allow_blank=True) + RegCode = serializers.CharField(required=False, allow_blank=True) + SSN = serializers.CharField(required=False, allow_blank=True) + MobileNo = serializers.CharField(required=False, allow_blank=True) class HISPatientDataSerializer(serializers.Serializer): """ Serializer for real HIS patient data format. - + This validates the structure of HIS data received from the simulator or actual HIS system. - + Example structure: { "FetchPatientDataTimeStampList": [{...patient demographics...}], - "FetchPatientDataTimeStampVisitDataList": [ - {"Type": "Consultation", "BillDate": "05-Jun-2025 11:06"}, - ... - ], + "FetchPatientDataTimeStampVisitEDDataList": [...], + "FetchPatientDataTimeStampVisitIPDataList": [...], + "FetchPatientDataTimeStampVisitOPDataList": [...], "Code": 200, "Status": "Success", "Message": "", @@ -191,33 +230,31 @@ class HISPatientDataSerializer(serializers.Serializer): "ValidateMessage": "" } """ + FetchPatientDataTimeStampList = HISPatientDemographicSerializer(many=True) - FetchPatientDataTimeStampVisitDataList = HISVisitDataSerializer(many=True) + FetchPatientDataTimeStampVisitEDDataList = HISVisitDataSerializer(many=True, required=False) + FetchPatientDataTimeStampVisitIPDataList = HISVisitDataSerializer(many=True, required=False) + FetchPatientDataTimeStampVisitOPDataList = HISVisitDataSerializer(many=True, required=False) + FetchPatientDataTimeStampVisitDataList = HISVisitDataSerializer(many=True, required=False) Code = serializers.IntegerField() Status = serializers.CharField() Message = serializers.CharField(required=False, allow_blank=True) Message2L = serializers.CharField(required=False, allow_blank=True) MobileNo = serializers.CharField(required=False, allow_blank=True) ValidateMessage = serializers.CharField(required=False, allow_blank=True) - + def validate(self, data): """Validate HIS data structure""" # Validate status - if data.get('Code') != 200: - raise serializers.ValidationError( - f"HIS returned error code: {data.get('Code')}" - ) - - if data.get('Status') != 'Success': - raise serializers.ValidationError( - f"HIS status not successful: {data.get('Status')}" - ) - + if data.get("Code") != 200: + raise serializers.ValidationError(f"HIS returned error code: {data.get('Code')}") + + if data.get("Status") != "Success": + raise serializers.ValidationError(f"HIS status not successful: {data.get('Status')}") + # Ensure patient data exists - patient_list = data.get('FetchPatientDataTimeStampList', []) + patient_list = data.get("FetchPatientDataTimeStampList", []) if not patient_list: - raise serializers.ValidationError( - "No patient data found in FetchPatientDataTimeStampList" - ) - + raise serializers.ValidationError("No patient data found in FetchPatientDataTimeStampList") + return data diff --git a/apps/integrations/services/his_adapter.py b/apps/integrations/services/his_adapter.py index 83e5f69..e2c4506 100644 --- a/apps/integrations/services/his_adapter.py +++ b/apps/integrations/services/his_adapter.py @@ -7,157 +7,281 @@ internal format for sending surveys based on PatientType. Simplified Flow: 1. Parse HIS patient data 2. Determine survey type from PatientType -3. Create survey instance with PENDING status -4. Queue delayed send task -5. Survey sent after delay (e.g., 1 hour for OPD) +3. Create/update HISPatientVisit record +4. Create patient record (always) +5. Create survey instance with PENDING status (only when visit complete) +6. Queue delayed send task +7. Survey sent after delay (e.g., 1 hour for OPD) """ + from datetime import datetime, timedelta -from typing import Dict, Optional, Tuple +from typing import Dict, List, Optional, Tuple import logging from django.utils import timezone -from apps.organizations.models import Hospital, Patient +from apps.organizations.models import Hospital, Patient, Staff from apps.surveys.models import SurveyTemplate, SurveyInstance, SurveyStatus -from apps.integrations.models import InboundEvent +from apps.integrations.models import InboundEvent, HISPatientVisit logger = logging.getLogger(__name__) +PATIENT_TYPE_TO_VISIT_LIST = { + "ED": "FetchPatientDataTimeStampVisitEDDataList", + "IP": "FetchPatientDataTimeStampVisitIPDataList", + "OP": "FetchPatientDataTimeStampVisitOPDataList", +} + class HISAdapter: """ Adapter for transforming HIS patient data format to internal format. - + HIS Data Structure: { "FetchPatientDataTimeStampList": [{...patient demographics...}], - "FetchPatientDataTimeStampVisitDataList": [ - {"Type": "Consultation", "BillDate": "05-Jun-2025 11:06"}, - ... - ], + "FetchPatientDataTimeStampVisitEDDataList": [...], + "FetchPatientDataTimeStampVisitIPDataList": [...], + "FetchPatientDataTimeStampVisitOPDataList": [...], "Code": 200, "Status": "Success" } - + PatientType Codes: - - "1" → Inpatient - - "2" or "O" → OPD (Outpatient) - - "3" or "E" → EMS (Emergency) + - "1" -> Inpatient + - "2" or "O" -> OPD (Outpatient) + - "3" or "E" -> EMS (Emergency) """ - + @staticmethod def parse_date(date_str: Optional[str]) -> Optional[datetime]: """Parse HIS date format 'DD-Mon-YYYY HH:MM' to timezone-aware datetime""" if not date_str: return None - + try: - # HIS format: "05-Jun-2025 11:06" naive_dt = datetime.strptime(date_str, "%d-%b-%Y %H:%M") - # Make timezone-aware using Django's timezone - from django.utils import timezone return timezone.make_aware(naive_dt) except ValueError: return None - + @staticmethod def map_patient_type_to_survey_type(patient_type: str) -> str: """ - Map HIS PatientType code to survey type name. - + Map HIS PatientType string to survey type name. + + Based on HIS sample data: + - "IP" -> Inpatient + - "OP" -> Outpatient + - "ED" -> Emergency + Returns survey type name for template lookup. """ - if patient_type == "1": + patient_type_upper = patient_type.upper() if patient_type else "" + + if patient_type_upper == "IP": return "INPATIENT" - elif patient_type in ["2", "O"]: + elif patient_type_upper == "OP": return "OPD" - elif patient_type in ["3", "E"]: + elif patient_type_upper == "ED": return "EMS" - elif patient_type == "4": + elif patient_type_upper == "DAYCASE": return "DAYCASE" + elif patient_type_upper == "APPOINTMENT": + return "APPOINTMENT" else: - # Default to OPD if unknown return "OPD" - + @staticmethod def split_patient_name(full_name: str) -> Tuple[str, str]: """Split patient name into first and last name""" - # Handle names like "AFAF NASSER ALRAZoooOOQ" parts = full_name.strip().split() if len(parts) == 1: return parts[0], "" elif len(parts) == 2: return parts[0], parts[1] else: - # Multiple parts - first is first name, rest is last name return parts[0], " ".join(parts[1:]) - + + @staticmethod + def extract_patient_visits(his_data: Dict, patient_id: str, patient_type: str) -> List[Dict]: + """ + Extract visit events for a specific patient from the appropriate sublist. + + Uses PatientID to match and PatientType to determine which sublist to use: + - "ED" -> FetchPatientDataTimeStampVisitEDDataList + - "IP" -> FetchPatientDataTimeStampVisitIPDataList + - "OP" -> FetchPatientDataTimeStampVisitOPDataList + + Args: + his_data: Full HIS response dict + patient_id: HIS PatientID to filter by + patient_type: HIS PatientType (ED, IP, OP) + + Returns: + List of visit event dicts for this patient, sorted by BillDate + """ + list_key = PATIENT_TYPE_TO_VISIT_LIST.get(patient_type.upper()) + if not list_key: + logger.warning(f"Unknown patient type '{patient_type}', no visit list found") + return [] + + all_visits = his_data.get(list_key, []) + + patient_visits = [] + for visit in all_visits: + if visit.get("PatientID") == str(patient_id): + visit_event = { + "type": visit.get("Type", ""), + "bill_date": visit.get("BillDate", ""), + "patient_type": visit.get("PatientType", patient_type), + "visit_category": patient_type, + "admission_id": visit.get("AdmissionID", ""), + "patient_id": visit.get("PatientID", ""), + "reg_code": visit.get("RegCode", ""), + "ssn": visit.get("SSN", ""), + "mobile_no": visit.get("MobileNo", ""), + } + + if visit_event["bill_date"]: + parsed_date = HISAdapter.parse_date(visit_event["bill_date"]) + visit_event["parsed_date"] = parsed_date.isoformat() if parsed_date else None + visit_event["sort_key"] = parsed_date if parsed_date else datetime.min + else: + visit_event["parsed_date"] = None + visit_event["sort_key"] = datetime.min + + patient_visits.append(visit_event) + + patient_visits.sort(key=lambda x: x.get("sort_key", datetime.min)) + + for visit in patient_visits: + visit.pop("sort_key", None) + + return patient_visits + + @staticmethod + def extract_visit_timeline(his_data: Dict) -> List[Dict]: + """ + Extract and sort visit timeline from HIS data (all patients). + + Combines visits from all 3 lists and sorts by BillDate. + Used for backward compatibility with the webhook flow. + """ + all_visits = [] + + visit_lists = [ + ("ED", his_data.get("FetchPatientDataTimeStampVisitEDDataList", [])), + ("IP", his_data.get("FetchPatientDataTimeStampVisitIPDataList", [])), + ("OP", his_data.get("FetchPatientDataTimeStampVisitOPDataList", [])), + ] + + for visit_type, visits in visit_lists: + for visit in visits: + visit_event = { + "type": visit.get("Type", ""), + "bill_date": visit.get("BillDate", ""), + "patient_type": visit.get("PatientType", visit_type), + "visit_category": visit_type, + "admission_id": visit.get("AdmissionID", ""), + "patient_id": visit.get("PatientID", ""), + "reg_code": visit.get("RegCode", ""), + "ssn": visit.get("SSN", ""), + "mobile_no": visit.get("MobileNo", ""), + } + + if visit_event["bill_date"]: + parsed_date = HISAdapter.parse_date(visit_event["bill_date"]) + visit_event["parsed_date"] = parsed_date.isoformat() if parsed_date else None + visit_event["sort_key"] = parsed_date if parsed_date else datetime.min + + all_visits.append(visit_event) + + all_visits.sort(key=lambda x: x.get("sort_key", datetime.min)) + + for visit in all_visits: + visit.pop("sort_key", None) + + return all_visits + @staticmethod def get_or_create_hospital(hospital_data: Dict) -> Optional[Hospital]: """Get or create hospital from HIS data""" hospital_name = hospital_data.get("HospitalName") hospital_id = hospital_data.get("HospitalID") - + if not hospital_name: return None - - # Try to find existing hospital by name + hospital = Hospital.objects.filter(name__icontains=hospital_name).first() - + if hospital: return hospital - - # If not found, create new hospital (optional - can be disabled in production) + hospital_code = hospital_id if hospital_id else f"HOSP-{hospital_name[:3].upper()}" - + hospital, created = Hospital.objects.get_or_create( - code=hospital_code, - defaults={ - 'name': hospital_name, - 'status': 'active' - } + code=hospital_code, defaults={"name": hospital_name, "status": "active"} ) - + return hospital - + @staticmethod def get_or_create_patient(patient_data: Dict, hospital: Hospital) -> Patient: """Get or create patient from HIS demographic data""" patient_id = patient_data.get("PatientID") - mrn = patient_id # PatientID serves as MRN + mrn = patient_id national_id = patient_data.get("SSN") phone = patient_data.get("MobileNo") email = patient_data.get("Email") full_name = patient_data.get("PatientName") - - # Split name + nationality = patient_data.get("PatientNationality", "") + first_name, last_name = HISAdapter.split_patient_name(full_name) - - # Parse date of birth + dob_str = patient_data.get("DOB") date_of_birth = HISAdapter.parse_date(dob_str) if dob_str else None - - # Extract additional info + gender = patient_data.get("Gender", "").lower() - - # Try to find existing patient by MRN + patient = Patient.objects.filter(mrn=mrn, primary_hospital=hospital).first() - + if patient: - # Update patient information if changed patient.first_name = first_name patient.last_name = last_name patient.national_id = national_id patient.phone = phone - # Only update email if it's not None (to avoid NOT NULL constraint) if email is not None: patient.email = email patient.date_of_birth = date_of_birth patient.gender = gender + patient.nationality = nationality patient.save() return patient - - # Create new patient + + mrn_taken = Patient.objects.filter(mrn=mrn).exists() + + if mrn_taken and national_id: + patient = Patient.objects.filter(national_id=national_id).first() + if patient: + patient.mrn = mrn + patient.primary_hospital = hospital + patient.first_name = first_name + patient.last_name = last_name + patient.phone = phone + if email is not None: + patient.email = email + patient.date_of_birth = date_of_birth + patient.gender = gender + patient.nationality = nationality + patient.save() + return patient + + if mrn_taken: + unique_mrn = f"{mrn}_{hospital.id}" + logger.warning(f"MRN collision for {mrn}, using {unique_mrn}") + mrn = unique_mrn + patient = Patient.objects.create( mrn=mrn, primary_hospital=hospital, @@ -165,33 +289,22 @@ class HISAdapter: last_name=last_name, national_id=national_id, phone=phone, - email=email if email else '', # Use empty string if email is None + email=email if email else "", date_of_birth=date_of_birth, - gender=gender + gender=gender, + nationality=nationality, ) - + return patient - + @staticmethod def get_survey_template(patient_type: str, hospital: Hospital) -> Optional[SurveyTemplate]: """ Get appropriate survey template based on PatientType using explicit mapping. - - Uses SurveyTemplateMapping to determine which template to send. - - Args: - patient_type: HIS PatientType code (1, 2, 3, 4, O, E, APPOINTMENT) - hospital: Hospital instance - - Returns: - SurveyTemplate or None if not found """ from apps.integrations.models import SurveyTemplateMapping - # Use explicit mapping to get template - survey_template = SurveyTemplateMapping.get_template_for_patient_type( - patient_type, hospital - ) + survey_template = SurveyTemplateMapping.get_template_for_patient_type(patient_type, hospital) return survey_template @@ -199,140 +312,588 @@ class HISAdapter: def get_delay_for_patient_type(patient_type: str, hospital) -> int: """ Get delay hours from SurveyTemplateMapping. - + Falls back to default delays if no mapping found. - - Args: - patient_type: HIS PatientType code (1, 2, 3, 4, O, E) - hospital: Hospital instance - - Returns: - Delay in hours """ from apps.integrations.models import SurveyTemplateMapping - - # Try to get mapping with delay (hospital-specific) + mapping = SurveyTemplateMapping.objects.filter( - patient_type=patient_type, - hospital=hospital, - is_active=True + patient_type=patient_type, hospital=hospital, is_active=True ).first() - + if mapping and mapping.send_delay_hours: return mapping.send_delay_hours - - # Fallback to global mapping + mapping = SurveyTemplateMapping.objects.filter( - patient_type=patient_type, - hospital__isnull=True, - is_active=True + patient_type=patient_type, hospital__isnull=True, is_active=True ).first() - + if mapping and mapping.send_delay_hours: return mapping.send_delay_hours - - # Default delays by patient type + default_delays = { - '1': 24, # Inpatient - 24 hours - '2': 1, # OPD - 1 hour - '3': 2, # EMS - 2 hours - 'O': 1, # OPD - 1 hour - 'E': 2, # EMS - 2 hours - '4': 4, # Daycase - 4 hours + "IP": 24, + "OP": 1, + "ED": 2, + "DAYCASE": 4, } - - return default_delays.get(patient_type, 1) # Default 1 hour + + return default_delays.get(patient_type, 1) + + @staticmethod + def is_op_visit_complete( + visit_timeline: List[Dict], patient_type: str, hospital + ) -> Tuple[bool, Optional[datetime]]: + """ + Check if an OP visit is complete by checking if the last visit event + is older than the configured send_delay_hours. + + Args: + visit_timeline: List of visit events for this patient + patient_type: HIS PatientType code + hospital: Hospital instance + + Returns: + Tuple of (is_complete: bool, last_visit_date: datetime or None) + """ + if not visit_timeline: + return False, None + + last_visit_date = None + for event in visit_timeline: + if event.get("bill_date"): + parsed = HISAdapter.parse_date(event["bill_date"]) + if parsed and (last_visit_date is None or parsed > last_visit_date): + last_visit_date = parsed + + if not last_visit_date: + return False, None + + delay_hours = HISAdapter.get_delay_for_patient_type(patient_type, hospital) + cutoff = timezone.now() - timedelta(hours=delay_hours) + + is_complete = last_visit_date <= cutoff + + return is_complete, last_visit_date + + @staticmethod + def save_patient_visit( + patient: Patient, + hospital: Hospital, + patient_data: Dict, + visit_timeline: List[Dict], + is_visit_complete: bool = False, + discharge_date: Optional[datetime] = None, + effective_discharge_date: Optional[datetime] = None, + ) -> HISPatientVisit: + """ + Create or update HISPatientVisit record. + + This is called on every fetch for every patient, regardless of + whether the visit is complete. + """ + admission_id = patient_data.get("AdmissionID", "") + patient_id_his = str(patient_data.get("PatientID", "")) + patient_type = patient_data.get("PatientType", "") + reg_code = patient_data.get("RegCode", "") + + admit_date_str = patient_data.get("AdmitDate") + admit_date = HISAdapter.parse_date(admit_date_str) if admit_date_str else None + + is_vip = str(patient_data.get("IsVIP", "0")).strip() == "1" + + visit, created = HISPatientVisit.objects.update_or_create( + admission_id=admission_id, + defaults={ + "patient": patient, + "hospital": hospital, + "reg_code": reg_code, + "patient_id_his": patient_id_his, + "patient_type": patient_type, + "admit_date": admit_date, + "discharge_date": discharge_date, + "effective_discharge_date": effective_discharge_date, + "visit_data": patient_data, + "visit_timeline": visit_timeline, + "primary_doctor": patient_data.get("PrimaryDoctor", ""), + "consultant_id": patient_data.get("ConsultantID", ""), + "company_name": patient_data.get("CompanyName", ""), + "grade_name": patient_data.get("GradeName", ""), + "insurance_company_name": patient_data.get("InsuranceCompanyName", ""), + "bill_type": patient_data.get("BillType", ""), + "is_vip": is_vip, + "nationality": patient_data.get("PatientNationality", ""), + "is_visit_complete": is_visit_complete, + "last_his_fetch_at": timezone.now(), + }, + ) + + HISAdapter._resolve_staff_fks(visit, patient_data) + HISAdapter._sync_visit_events(visit, visit_timeline) + + action = "Created" if created else "Updated" + logger.info( + f"{action} HISPatientVisit: {admission_id} type={patient_type} complete={is_visit_complete} patient={patient}" + ) + + return visit + + @staticmethod + def _resolve_staff_fks(visit: HISPatientVisit, patient_data: Dict) -> None: + """ + Match HIS doctor/consultant IDs to Staff records via employee_id. + + HIS formats: + - ConsultantID: "11065" (numeric string only, no name) + - PrimaryDoctor: "16468-HEBA ELSHABOURY ABDELATTY" (ID prefix + dash + name) + + PrimaryDoctor: get_or_create on employee_id (auto-creates Staff from HIS name). + ConsultantID: lookup only (no creation — no name available from HIS). + Uses .update() to avoid extra queries or save signals. + """ + updates = {} + + consultant_raw = patient_data.get("ConsultantID", "").strip() + if consultant_raw and consultant_raw.isdigit(): + consultant = Staff.objects.filter(employee_id=consultant_raw).first() + if consultant: + updates["consultant_fk"] = consultant + + primary_doctor_raw = patient_data.get("PrimaryDoctor", "").strip() + if primary_doctor_raw and "-" in primary_doctor_raw: + doctor_code, doctor_name = primary_doctor_raw.split("-", 1) + doctor_code = doctor_code.strip() + doctor_name = doctor_name.strip() + if doctor_code.isdigit() and doctor_name: + defaults = HISAdapter._staff_defaults_from_name(doctor_name, visit.hospital) + doctor, created = Staff.objects.get_or_create( + employee_id=doctor_code, + defaults=defaults, + ) + if created: + logger.info(f"Auto-created Staff from HIS: {doctor} (employee_id={doctor_code})") + updates["primary_doctor_fk"] = doctor + + if updates: + HISPatientVisit.objects.filter(pk=visit.pk).update(**updates) + + @staticmethod + def _staff_defaults_from_name(full_name: str, hospital: Hospital) -> Dict: + """ + Build Staff defaults dict from a raw HIS doctor name string. + + E.g. "HEBA ELSHABOURY ABDELATTY" → first_name="HEBA", last_name="ELSABOURY ABDELATTY" + """ + parts = full_name.split(None, 1) + first_name = parts[0] if parts else "Doctor" + last_name = parts[1] if len(parts) > 1 else "" + return { + "first_name": first_name, + "last_name": last_name, + "name": full_name, + "staff_type": Staff.StaffType.PHYSICIAN, + "job_title": "Physician", + "hospital": hospital, + "physician": True, + } + + @staticmethod + def _sync_visit_events(visit: HISPatientVisit, visit_timeline: List[Dict]) -> None: + """ + Sync timeline events to HISVisitEvent model. + + Creates/updates HISVisitEvent records from the timeline JSON. + Also auto-creates HISEventType records for unique event types. + """ + from apps.integrations.models import HISVisitEvent, HISEventType + + if not visit_timeline: + return + + existing_keys = set(HISVisitEvent.objects.filter(visit=visit).values_list("bill_date", "event_type")) + + events_to_create = [] + event_types_seen = set() + for event in visit_timeline: + bill_date = event.get("bill_date", "") + event_type = event.get("type", "") + patient_type = event.get("patient_type", "") + key = (bill_date, event_type) + + if key in existing_keys: + continue + + parsed_date_str = event.get("parsed_date") + parsed_date = None + if parsed_date_str: + try: + from datetime import datetime + + parsed_date = datetime.fromisoformat(parsed_date_str.replace("Z", "+00:00")) + except (ValueError, TypeError): + pass + + events_to_create.append( + HISVisitEvent( + visit=visit, + event_type=event_type, + bill_date=bill_date, + parsed_date=parsed_date, + patient_type=patient_type, + visit_category=event.get("visit_category", ""), + admission_id=event.get("admission_id", ""), + patient_id=event.get("patient_id", ""), + reg_code=event.get("reg_code", ""), + ssn=event.get("ssn", ""), + mobile_no=event.get("mobile_no", ""), + ) + ) + + if event_type: + event_types_seen.add((event_type, patient_type)) + + if events_to_create: + HISVisitEvent.objects.bulk_create(events_to_create, ignore_conflicts=True) + logger.info(f"Created {len(events_to_create)} HISVisitEvent records for visit {visit.admission_id}") + + if event_types_seen: + from django.db.models import F + + for event_type, patient_type in event_types_seen: + et, created = HISEventType.objects.get_or_create(event_type=event_type) + if patient_type and patient_type not in et.patient_types: + et.patient_types.append(patient_type) + et.save(update_fields=["patient_types"]) + HISEventType.objects.filter(id=et.id).update(event_count=F("event_count") + 1) @staticmethod def create_and_send_survey( patient: Patient, hospital: Hospital, patient_data: Dict, - survey_template: SurveyTemplate + survey_template: SurveyTemplate = None, + visit_timeline: List[Dict] = None, + his_visit: HISPatientVisit = None, ) -> Optional[SurveyInstance]: """ - Create survey instance and queue for delayed sending. - - NEW: Survey is created with PENDING status and sent after delay. - - Args: - patient: Patient instance - hospital: Hospital instance - patient_data: HIS patient data - survey_template: SurveyTemplate instance - - Returns: - SurveyInstance or None if failed + Create survey instance using the template from SurveyTemplateMapping. + + The template's questions are filtered at display time by the public + serializer - is_base questions are always shown, event_type questions + are only shown if the patient experienced that event. """ + from apps.surveys.models import ( + SurveyInstance, + SurveyStatus, + ) from apps.surveys.tasks import send_scheduled_survey - + admission_id = patient_data.get("AdmissionID") discharge_date_str = patient_data.get("DischargeDate") patient_type = patient_data.get("PatientType") - - # Check if survey already sent for this admission + existing_survey = SurveyInstance.objects.filter( - patient=patient, - hospital=hospital, - metadata__admission_id=admission_id + patient=patient, hospital=hospital, metadata__admission_id=admission_id ).first() - + if existing_survey: logger.info(f"Survey already exists for admission {admission_id}") + if his_visit and not his_visit.survey_instance: + his_visit.survey_instance = existing_survey + his_visit.save(update_fields=["survey_instance"]) return existing_survey - - # Get delay from SurveyTemplateMapping + + if not survey_template: + survey_template = HISAdapter.get_survey_template(patient_type, hospital) + + if not survey_template: + logger.warning(f"No survey template mapping found for {patient_type} at {hospital}") + return None + delay_hours = HISAdapter.get_delay_for_patient_type(patient_type, hospital) - - # Calculate scheduled send time scheduled_send_at = timezone.now() + timedelta(hours=delay_hours) - - # Create survey with PENDING status (NOT SENT) + + effective_discharge = None + if his_visit and his_visit.effective_discharge_date: + effective_discharge = his_visit.effective_discharge_date.isoformat() + + # Collect unique event types from patient's journey for metadata + event_types = [] + if his_visit: + events_qs = his_visit.visit_events.order_by("parsed_date") + for evt in events_qs: + if evt.event_type and evt.event_type not in event_types: + event_types.append(evt.event_type) + survey = SurveyInstance.objects.create( survey_template=survey_template, patient=patient, hospital=hospital, - status=SurveyStatus.PENDING, # Changed from SENT + status=SurveyStatus.PENDING, delivery_channel="SMS", recipient_phone=patient.phone, recipient_email=patient.email, scheduled_send_at=scheduled_send_at, metadata={ - 'admission_id': admission_id, - 'patient_type': patient_type, - 'hospital_id': patient_data.get("HospitalID"), - 'insurance_company': patient_data.get("InsuranceCompanyName"), - 'is_vip': patient_data.get("IsVIP") == "1", - 'discharge_date': discharge_date_str, - 'scheduled_send_at': scheduled_send_at.isoformat(), - 'delay_hours': delay_hours, - } + "admission_id": admission_id, + "patient_type": patient_type, + "hospital_id": patient_data.get("HospitalID"), + "insurance_company": patient_data.get("InsuranceCompanyName"), + "is_vip": patient_data.get("IsVIP") == "1", + "discharge_date": discharge_date_str, + "effective_discharge_date": effective_discharge, + "scheduled_send_at": scheduled_send_at.isoformat(), + "delay_hours": delay_hours, + "visit_timeline": visit_timeline or [], + "event_types": event_types, + "question_source": "template", + }, ) - - # Queue delayed send task - send_scheduled_survey.apply_async( - args=[str(survey.id)], - countdown=delay_hours * 3600 # Convert to seconds - ) - + + send_scheduled_survey.apply_async(args=[str(survey.id)], countdown=delay_hours * 3600) + + if his_visit: + his_visit.survey_instance = survey + his_visit.save(update_fields=["survey_instance"]) + logger.info( - f"Survey {survey.id} created for {patient_type}, " + f"Survey {survey.id} created for {patient_type} (template: {survey_template.name}), " f"will send in {delay_hours}h at {scheduled_send_at}" ) - + return survey - + + @staticmethod + def process_single_patient(his_data: Dict, patient_data: Dict) -> Dict: + """ + Process a single patient from HIS data. + + Two phases: + Phase A: Always save patient and visit data + Phase B: Create survey only if visit is complete + + Args: + his_data: Full HIS response dict (needed for visit timeline extraction) + patient_data: Single patient dict from FetchPatientDataTimeStampList + + Returns: + Dict with processing results + """ + result = { + "success": False, + "message": "", + "patient": None, + "survey": None, + "survey_queued": False, + "visit_saved": False, + "visit_complete": False, + "patient_type": None, + } + + try: + patient_type = patient_data.get("PatientType") + patient_id = str(patient_data.get("PatientID", "")) + discharge_date_str = patient_data.get("DischargeDate") + + hospital = HISAdapter.get_or_create_hospital(patient_data) + if not hospital: + result["message"] = "Could not determine hospital" + return result + + patient = HISAdapter.get_or_create_patient(patient_data, hospital) + result["patient"] = patient + result["patient_type"] = patient_type + + visit_timeline = HISAdapter.extract_patient_visits(his_data, patient_id, patient_type) + + discharge_date = HISAdapter.parse_date(discharge_date_str) if discharge_date_str else None + + is_visit_complete = False + effective_discharge_date = None + + if patient_type and patient_type.upper() in ("ED", "IP"): + if discharge_date: + is_visit_complete = True + else: + result["message"] = f"{patient_type} patient not discharged - visit saved, no survey" + result["success"] = True + result["visit_saved"] = True + HISAdapter.save_patient_visit( + patient, + hospital, + patient_data, + visit_timeline, + is_visit_complete=False, + discharge_date=None, + ) + return result + + elif patient_type and patient_type.upper() == "OP": + is_complete, last_visit_date = HISAdapter.is_op_visit_complete(visit_timeline, patient_type, hospital) + if is_complete: + is_visit_complete = True + effective_discharge_date = last_visit_date + else: + result["message"] = f"OP visit still in progress (last activity: {last_visit_date})" + result["success"] = True + result["visit_saved"] = True + result["visit_complete"] = False + HISAdapter.save_patient_visit( + patient, + hospital, + patient_data, + visit_timeline, + is_visit_complete=False, + discharge_date=None, + ) + return result + else: + if discharge_date: + is_visit_complete = True + else: + result["message"] = f"Patient type {patient_type} not discharged - visit saved, no survey" + result["success"] = True + result["visit_saved"] = True + HISAdapter.save_patient_visit( + patient, + hospital, + patient_data, + visit_timeline, + is_visit_complete=False, + discharge_date=None, + ) + return result + + his_visit = HISAdapter.save_patient_visit( + patient, + hospital, + patient_data, + visit_timeline, + is_visit_complete=is_visit_complete, + discharge_date=discharge_date, + effective_discharge_date=effective_discharge_date, + ) + result["visit_saved"] = True + result["visit_complete"] = True + + if his_visit.survey_instance: + result["message"] = f"Survey already exists for admission {patient_data.get('AdmissionID')}" + result["success"] = True + result["survey"] = his_visit.survey_instance + result["survey_queued"] = True + return result + + survey = HISAdapter.create_and_send_survey( + patient, hospital, patient_data, visit_timeline=visit_timeline, his_visit=his_visit + ) + + if survey: + survey_queued = survey.status == SurveyStatus.PENDING + else: + survey_queued = False + + result.update( + { + "success": True, + "message": "Patient data processed successfully", + "survey": survey, + "survey_queued": survey_queued, + "scheduled_send_at": survey.scheduled_send_at.isoformat() + if survey and survey.scheduled_send_at + else None, + "survey_url": survey.get_survey_url() if survey else None, + } + ) + + except Exception as e: + logger.error(f"Error processing HIS data: {str(e)}", exc_info=True) + result["message"] = f"Error processing HIS data: {str(e)}" + result["success"] = False + + return result + + @staticmethod + def process_his_response(his_data: Dict) -> Dict: + """ + Process a full HIS API response containing multiple patients. + + Iterates each patient in FetchPatientDataTimeStampList, extracts + their visits from the appropriate sublist, saves patient/visit data, + and creates surveys for completed visits. + + Args: + his_data: Full HIS response dict + + Returns: + Dict with summary of processing results + """ + result = { + "success": False, + "total_patients": 0, + "visits_saved": 0, + "surveys_created": 0, + "surveys_skipped": 0, + "errors": [], + "details": [], + } + + try: + if his_data.get("Code") != 200 or his_data.get("Status") != "Success": + result["errors"].append(f"HIS Error: {his_data.get('Message', 'Unknown error')}") + return result + + patient_list = his_data.get("FetchPatientDataTimeStampList", []) + + if not patient_list: + result["message"] = "No patient data found" + result["success"] = True + return result + + result["total_patients"] = len(patient_list) + + for patient_data in patient_list: + patient_result = HISAdapter.process_single_patient(his_data, patient_data) + + detail = { + "patient_name": patient_data.get("PatientName", "Unknown"), + "patient_type": patient_data.get("PatientType"), + "admission_id": patient_data.get("AdmissionID"), + } + + if patient_result["success"]: + if patient_result.get("visit_saved"): + result["visits_saved"] += 1 + + if patient_result.get("visit_complete") and patient_result.get("survey"): + result["surveys_created"] += 1 + detail["survey_created"] = True + elif patient_result.get("visit_complete") and not patient_result.get("survey"): + result["surveys_skipped"] += 1 + detail["reason"] = patient_result.get("message", "No survey created") + else: + detail["reason"] = patient_result.get("message", "Visit in progress") + else: + result["errors"].append(f"{patient_data.get('PatientName')}: {patient_result.get('message')}") + detail["error"] = patient_result.get("message") + + result["details"].append(detail) + + result["success"] = True + + except Exception as e: + logger.error(f"Error processing HIS response: {str(e)}", exc_info=True) + result["errors"].append(str(e)) + + return result + @staticmethod def process_his_data(his_data: Dict) -> Dict: """ - Main method to process HIS patient data and send surveys. + Process HIS patient data (webhook/push flow). - Simplified Flow: - 1. Extract patient data - 2. Get or create patient and hospital - 3. Determine survey type from PatientType - 4. Create survey with PENDING status - 5. Queue delayed send task + Handles a full HIS payload received via webhook. + Processes the first patient in the list. + For the pull flow, use process_his_response() instead. Args: his_data: HIS data in real format @@ -340,81 +901,14 @@ class HISAdapter: Returns: Dict with processing results """ - result = { - 'success': False, - 'message': '', - 'patient': None, - 'survey': None, - 'survey_queued': False - } + patient_list = his_data.get("FetchPatientDataTimeStampList", []) - try: - # Extract patient data - patient_list = his_data.get("FetchPatientDataTimeStampList", []) + if not patient_list: + return {"success": False, "message": "No patient data found"} - if not patient_list: - result['message'] = "No patient data found" - return result + patient_data = patient_list[0] - patient_data = patient_list[0] + if his_data.get("Code") != 200 or his_data.get("Status") != "Success": + return {"success": False, "message": f"HIS Error: {his_data.get('Message', 'Unknown error')}"} - # Validate status - if his_data.get("Code") != 200 or his_data.get("Status") != "Success": - result['message'] = f"HIS Error: {his_data.get('Message', 'Unknown error')}" - return result - - # Check if patient is discharged (required for ALL patient types) - patient_type = patient_data.get("PatientType") - discharge_date_str = patient_data.get("DischargeDate") - - # All patient types require discharge date - if not discharge_date_str: - result['message'] = f'Patient type {patient_type} not discharged - no survey sent' - result['success'] = True # Not an error, just no action needed - return result - - # Get or create hospital - hospital = HISAdapter.get_or_create_hospital(patient_data) - if not hospital: - result['message'] = "Could not determine hospital" - return result - - # Get or create patient - patient = HISAdapter.get_or_create_patient(patient_data, hospital) - - # Get survey template based on PatientType - patient_type = patient_data.get("PatientType") - survey_template = HISAdapter.get_survey_template(patient_type, hospital) - - if not survey_template: - result['message'] = f"No survey template found for patient type '{patient_type}'" - return result - - # Create and queue survey (delayed sending) - survey = HISAdapter.create_and_send_survey( - patient, hospital, patient_data, survey_template - ) - - if survey: - # Survey is queued with PENDING status - survey_queued = survey.status == SurveyStatus.PENDING - else: - survey_queued = False - - result.update({ - 'success': True, - 'message': 'Patient data processed successfully', - 'patient': patient, - 'patient_type': patient_type, - 'survey': survey, - 'survey_queued': survey_queued, - 'scheduled_send_at': survey.scheduled_send_at.isoformat() if survey and survey.scheduled_send_at else None, - 'survey_url': survey.get_survey_url() if survey else None - }) - - except Exception as e: - logger.error(f"Error processing HIS data: {str(e)}", exc_info=True) - result['message'] = f"Error processing HIS data: {str(e)}" - result['success'] = False - - return result \ No newline at end of file + return HISAdapter.process_single_patient(his_data, patient_data) diff --git a/apps/integrations/services/his_client.py b/apps/integrations/services/his_client.py index 7d6e5f8..3141880 100644 --- a/apps/integrations/services/his_client.py +++ b/apps/integrations/services/his_client.py @@ -77,213 +77,183 @@ class HISClient: return (self.username, self.password) return None - def fetch_patients( - self, since: Optional[datetime] = None, patient_type: Optional[str] = None, limit: int = 100 - ) -> List[Dict]: + def fetch_patient_data(self, since: Optional[datetime] = None, until: Optional[datetime] = None) -> Optional[Dict]: """ - Fetch patients from HIS system. + Fetch full patient data from HIS system including visit timelines. + + Returns the complete HIS response dict with all arrays intact: + - FetchPatientDataTimeStampList + - FetchPatientDataTimeStampVisitEDDataList + - FetchPatientDataTimeStampVisitIPDataList + - FetchPatientDataTimeStampVisitOPDataList Args: - since: Only fetch patients updated since this datetime - patient_type: Filter by patient type (1=Inpatient, 2=OPD, 3=EMS) - limit: Maximum number of patients to fetch + since: Only fetch patients since this datetime + until: Only fetch patients until this datetime (optional) Returns: - List of patient data dictionaries in HIS format + Full HIS response dict or None if error """ api_url = self._get_api_url() if not api_url: logger.error("No HIS API URL configured") - return [] + return None try: - # Build URL with query parameters for the new endpoint format if since: - # Calculate end time (5 minutes window since this runs every 5 minutes) - end_time = since + timedelta(minutes=5) - - # Format dates for HIS API: DD-Mon-YYYY HH:MM:SS - from_date = self._format_datetime(since) - to_date = self._format_datetime(end_time) - - # Build URL with parameters (SSN=0 and MobileNo=0 to get all) - url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0" + end_time = until if until else timezone.now() else: - # If no since time, use last 10 minutes as default end_time = timezone.now() - start_time = end_time - timedelta(minutes=10) + since = end_time - timedelta(minutes=5) - from_date = self._format_datetime(start_time) - to_date = self._format_datetime(end_time) + from_date = self._format_datetime(since) + to_date = self._format_datetime(end_time) - url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0" + url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0" - logger.info(f"Fetching patients from HIS: {url}") + logger.info(f"Fetching patient data from HIS: {url}") - # Make request with Basic Auth response = self.session.get( url, headers=self._get_headers(), auth=self._get_auth(), timeout=30, - verify=True, # Set to False if SSL certificate issues + verify=True, ) response.raise_for_status() - # Parse response data = response.json() - # Handle different response formats - if isinstance(data, list): - patients = data - elif isinstance(data, dict): - patients = data.get("FetchPatientDataTimeStampList", []) - # Alternative formats - if not patients: - patients = data.get("patients", []) - if not patients: - patients = data.get("data", []) + if isinstance(data, dict): + patient_count = len(data.get("FetchPatientDataTimeStampList", [])) + logger.info(f"Fetched {patient_count} patients from HIS") + return data else: - patients = [] - - logger.info(f"Fetched {len(patients)} patients from HIS") - return patients + logger.error(f"Unexpected HIS response type: {type(data)}") + return None except requests.exceptions.RequestException as e: - logger.error(f"Error fetching patients from HIS: {e}") - return [] + logger.error(f"Error fetching patient data from HIS: {e}") + return None except Exception as e: - logger.error(f"Unexpected error fetching patients: {e}") - return [] + logger.error(f"Unexpected error fetching patient data from HIS: {e}") + return None - # Build query parameters - params = {"limit": limit} - - if since: - # Format: DD-Mon-YYYY HH:MM (HIS format) - params["since"] = self._format_datetime(since) - - if patient_type: - params["patient_type"] = patient_type - - # Additional config params from IntegrationConfig - if self.config and self.config.config_json: - config_params = self.config.config_json.get("fetch_params", {}) - params.update(config_params) - - try: - logger.info(f"Fetching patients from HIS: {api_url}") - - response = self.session.get(api_url, headers=self._get_headers(), params=params, timeout=30) - response.raise_for_status() - - data = response.json() - - # Handle different response formats - if isinstance(data, list): - patients = data - elif isinstance(data, dict): - # Standard HIS format - patients = data.get("FetchPatientDataTimeStampList", []) - # Alternative formats - if not patients: - patients = data.get("patients", []) - if not patients: - patients = data.get("data", []) - else: - patients = [] - - logger.info(f"Fetched {len(patients)} patients from HIS") - return patients - - except requests.exceptions.RequestException as e: - logger.error(f"Error fetching patients from HIS: {e}") - return [] - except Exception as e: - logger.error(f"Unexpected error fetching patients: {e}") - return [] - - def fetch_discharged_patients(self, since: Optional[datetime] = None, limit: int = 100) -> List[Dict]: + def fetch_doctor_ratings(self, from_date: datetime, to_date: datetime) -> Optional[Dict]: """ - Fetch discharged patients who need surveys. - - This is the main method for survey fetching - only discharged - patients are eligible for surveys. + Fetch doctor ratings from HIS FetchDoctorRatingMAPI1 endpoint. Args: - since: Only fetch patients discharged since this datetime - limit: Maximum number of patients to fetch + from_date: Start date for ratings + to_date: End date for ratings Returns: - List of patient data dictionaries in HIS format + HIS response dict with FetchDoctorRatingMAPI1List or None on error """ - api_url = self._get_api_url() + api_url = os.getenv("HIS_RATINGS_API_URL") if not api_url: - logger.error("No HIS API URL configured") - return [] + logger.error("HIS_RATINGS_API_URL not configured in environment") + return None try: - # Build URL with query parameters for the new endpoint format - if since: - # Calculate end time (5 minutes window since this runs every 5 minutes) - end_time = since + timedelta(minutes=5) + from_date_str = self._format_datetime(from_date) + to_date_str = self._format_datetime(to_date) - # Format dates for HIS API: DD-Mon-YYYY HH:MM:SS - from_date = self._format_datetime(since) - to_date = self._format_datetime(end_time) + url = f"{api_url}?FromDate={quote(from_date_str)}&ToDate={quote(to_date_str)}&SSN=0&MobileNo=0" - # Build URL with parameters (SSN=0 and MobileNo=0 to get all) - url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0" - else: - # If no since time, use last 10 minutes as default - end_time = timezone.now() - start_time = end_time - timedelta(minutes=10) + logger.info(f"Fetching doctor ratings from HIS: {url}") - from_date = self._format_datetime(start_time) - to_date = self._format_datetime(end_time) - - url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0" - - logger.info(f"Fetching discharged patients from HIS: {url}") - - # Make request with Basic Auth response = self.session.get( url, headers=self._get_headers(), auth=self._get_auth(), timeout=30, - verify=True, # Set to False if SSL certificate issues + verify=True, ) response.raise_for_status() data = response.json() - # Parse response - if isinstance(data, list): - patients = data - elif isinstance(data, dict): - patients = data.get("FetchPatientDataTimeStampList", []) - if not patients: - patients = data.get("patients", []) - if not patients: - patients = data.get("data", []) + if isinstance(data, dict): + rating_count = len(data.get("FetchDoctorRatingMAPI1List", [])) + logger.info(f"Fetched {rating_count} doctor ratings from HIS") + return data else: - patients = [] - - # Filter only discharged patients - discharged = [ - p - for p in patients - if p.get("DischargeDate") - or (isinstance(p, dict) and p.get("FetchPatientDataTimeStampList", [{}])[0].get("DischargeDate")) - ] - - logger.info(f"Fetched {len(discharged)} discharged patients from HIS") - return discharged + logger.error(f"Unexpected HIS response type: {type(data)}") + return None except requests.exceptions.RequestException as e: - logger.error(f"Error fetching discharged patients: {e}") - return [] + logger.error(f"Error fetching doctor ratings from HIS: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching doctor ratings from HIS: {e}") + return None + + def fetch_patient_by_identifier(self, ssn: Optional[str] = None, mobile_no: Optional[str] = None) -> Optional[Dict]: + """ + Search HIS for a patient by SSN or Mobile number. + + Uses a wide date range (5 years) since we are searching by + identifier rather than date. Returns only the patient + demographics list from the HIS response. + + Args: + ssn: Patient SSN/National ID + mobile_no: Patient mobile number + + Returns: + List of patient demographic dicts from HIS, or None on error + """ + if not ssn and not mobile_no: + logger.warning("fetch_patient_by_identifier called without SSN or MobileNo") + return None + + api_url = self._get_api_url() + if not api_url: + logger.error("No HIS API URL configured") + return None + + try: + end_time = timezone.now() + since = end_time - timedelta(days=365 * 5) + + from_date = self._format_datetime(since) + to_date = self._format_datetime(end_time) + ssn_param = ssn or "0" + mobile_param = mobile_no or "0" + + url = ( + f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}" + f"&SSN={quote(ssn_param)}&MobileNo={quote(mobile_param)}" + ) + + logger.info(f"Searching HIS by identifier: SSN={ssn_param}, Mobile={mobile_param}") + + response = self.session.get( + url, + headers=self._get_headers(), + auth=self._get_auth(), + timeout=30, + verify=True, + ) + response.raise_for_status() + + data = response.json() + + if isinstance(data, dict): + patients = data.get("FetchPatientDataTimeStampList", []) + logger.info(f"HIS identifier search returned {len(patients)} patients") + return patients + else: + logger.error(f"Unexpected HIS response type: {type(data)}") + return None + + except requests.exceptions.RequestException as e: + logger.error(f"Error searching HIS by identifier: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error searching HIS by identifier: {e}") + return None def fetch_patient_visits(self, patient_id: str) -> List[Dict]: """ diff --git a/apps/integrations/tasks.py b/apps/integrations/tasks.py index 4302d32..9edd537 100644 --- a/apps/integrations/tasks.py +++ b/apps/integrations/tasks.py @@ -10,6 +10,7 @@ This module contains the core event processing logic that: """ import logging +from datetime import datetime from celery import shared_task from django.db import transaction @@ -208,163 +209,59 @@ def process_pending_events(): # ============================================================================= -@shared_task -def test_fetch_his_surveys_from_json(): - """ - TEST TASK - Fetch surveys from local JSON file instead of HIS API. - - This is a clone of fetch_his_surveys for testing purposes. - Reads from /home/ismail/projects/HH/data.json - - TODO: Remove this task after testing is complete. - - Returns: - dict: Summary of fetched and processed surveys - """ - import json - from pathlib import Path - from apps.integrations.services.his_adapter import HISAdapter - - logger.info("Starting TEST HIS survey fetch from JSON file") - - result = { - "success": False, - "patients_fetched": 0, - "surveys_created": 0, - "surveys_queued": 0, - "errors": [], - "details": [], - } - - try: - # Read JSON file - json_path = Path("/home/ismail/projects/HH/data.json") - if not json_path.exists(): - error_msg = f"JSON file not found: {json_path}" - logger.error(error_msg) - result["errors"].append(error_msg) - return result - - with open(json_path, 'r') as f: - his_data = json.load(f) - - # Extract patient list - patient_list = his_data.get("FetchPatientDataTimeStampList", []) - - if not patient_list: - logger.warning("No patient data found in JSON file") - result["errors"].append("No patient data found") - return result - - logger.info(f"Found {len(patient_list)} patients in JSON file") - result["patients_fetched"] = len(patient_list) - - # Process each patient - for patient_data in patient_list: - try: - # Wrap in proper format for HISAdapter - patient_payload = { - "FetchPatientDataTimeStampList": [patient_data], - "FetchPatientDataTimeStampVisitDataList": [], - "Code": 200, - "Status": "Success", - } - - # Process using HISAdapter - process_result = HISAdapter.process_his_data(patient_payload) - - if process_result["success"]: - result["surveys_created"] += 1 - - if process_result.get("survey_queued"): - result["surveys_queued"] += 1 - - # Log survey details - survey = process_result.get("survey") - if survey: - logger.info( - f"Survey queued for {patient_data.get('PatientName')}: " - f"Type={patient_data.get('PatientType')}, " - f"Scheduled={survey.scheduled_send_at}, " - f"Delay={process_result.get('metadata', {}).get('delay_hours', 'N/A')}h" - ) - else: - logger.info( - f"Survey created but not queued for {patient_data.get('PatientName')}" - ) - else: - # Not an error - patient may not be discharged - if "not discharged" in process_result.get("message", ""): - logger.debug( - f"Skipping {patient_data.get('PatientName')}: Not discharged" - ) - else: - logger.warning( - f"Failed to process {patient_data.get('PatientName')}: " - f"{process_result.get('message', 'Unknown error')}" - ) - result["errors"].append( - f"{patient_data.get('PatientName')}: {process_result.get('message')}" - ) - - except Exception as e: - error_msg = f"Error processing patient {patient_data.get('PatientName', 'Unknown')}: {str(e)}" - logger.error(error_msg, exc_info=True) - result["errors"].append(error_msg) - - result["success"] = True - - logger.info( - f"TEST HIS survey fetch completed: " - f"{result['patients_fetched']} patients, " - f"{result['surveys_created']} surveys created, " - f"{result['surveys_queued']} surveys queued" - ) - - except Exception as e: - error_msg = f"Fatal error in test_fetch_his_surveys_from_json: {str(e)}" - logger.error(error_msg, exc_info=True) - result["errors"].append(error_msg) - - return result +def _parse_his_date(date_str): + """Parse HIS date format 'DD-Mon-YYYY HH:MM:SS' to timezone-aware datetime.""" + if not date_str: + return None + for fmt in ("%d-%b-%Y %H:%M:%S", "%d-%b-%Y %H:%M"): + try: + naive = datetime.strptime(date_str, fmt) + return timezone.make_aware(naive) + except ValueError: + continue + return None @shared_task -def fetch_his_surveys(): +def fetch_his_surveys(from_date_str=None, to_date_str=None): """ - Periodic task to fetch surveys from HIS system every 5 minutes. + Periodic task to fetch patient data from HIS system every 5 minutes. This task: 1. Connects to configured HIS API endpoints - 2. Fetches discharged patients for the last 5-minute window - 3. Processes each patient using HISAdapter - 4. Creates and sends surveys via SMS + 2. Fetches all patients from the last 5 minutes (or custom date range) + 3. Saves patient and visit data for every patient (all types) + 4. Creates surveys only for patients whose visit is complete: + - ED/IP: DischargeDate present + - OP: last visit event is older than configured delay hours + + Args: + from_date_str: Optional override - fetch from this date (DD-Mon-YYYY HH:MM:SS) + to_date_str: Optional override - fetch until this date (DD-Mon-YYYY HH:MM:SS) Scheduled to run every 5 minutes via Celery Beat. Returns: - dict: Summary of fetched and processed surveys + dict: Summary of fetched and processed data """ - from datetime import timedelta + from datetime import datetime, timedelta - from apps.integrations.models import IntegrationConfig, SourceSystem from apps.integrations.services.his_adapter import HISAdapter from apps.integrations.services.his_client import HISClient, HISClientFactory - logger.info("Starting HIS survey fetch task") + logger.info("Starting HIS patient data fetch task") result = { "success": False, "clients_processed": 0, - "patients_fetched": 0, + "total_patients": 0, + "visits_saved": 0, "surveys_created": 0, - "surveys_sent": 0, "errors": [], "details": [], } try: - # Get all active HIS clients clients = HISClientFactory.get_all_active_clients() if not clients: @@ -373,23 +270,30 @@ def fetch_his_surveys(): result["errors"].append(msg) return result - # Calculate fetch window (last 5 minutes) - # This ensures we don't miss any patients due to timing issues - fetch_since = timezone.now() - timedelta(minutes=5) + if from_date_str and to_date_str: + fetch_since = _parse_his_date(from_date_str) + fetch_until = _parse_his_date(to_date_str) + if not fetch_since or not fetch_until: + msg = f"Invalid date format: from_date={from_date_str}, to_date={to_date_str}" + result["errors"].append(msg) + return result + logger.info(f"Using custom date range: {fetch_since} to {fetch_until}") + else: + fetch_since = timezone.now() - timedelta(minutes=5) + fetch_until = None - logger.info(f"Fetching discharged patients since {fetch_since}") + logger.info(f"Fetching patient data since {fetch_since}") for client in clients: client_result = { "config": client.config.name if client.config else "Default", - "patients_fetched": 0, + "total_patients": 0, + "visits_saved": 0, "surveys_created": 0, - "surveys_sent": 0, "errors": [], } try: - # Test connection first connection_test = client.test_connection() if not connection_test["success"]: error_msg = f"HIS connection failed for {client_result['config']}: {connection_test['message']}" @@ -398,59 +302,36 @@ def fetch_his_surveys(): result["details"].append(client_result) continue - logger.info(f"Fetching discharged patients from {client_result['config']} since {fetch_since}") + logger.info(f"Fetching patient data from {client_result['config']}") - # Fetch discharged patients for the 5-minute window - patients = client.fetch_discharged_patients( - since=fetch_since, - limit=100, # Max 100 patients per fetch - ) + his_data = client.fetch_patient_data(since=fetch_since, until=fetch_until) - client_result["patients_fetched"] = len(patients) - result["patients_fetched"] += len(patients) - - if not patients: - logger.info(f"No new discharged patients found in {client_result['config']}") + if not his_data: + logger.info(f"No data returned from {client_result['config']}") result["details"].append(client_result) continue - logger.info(f"Fetched {len(patients)} patients from {client_result['config']}") + patient_list = his_data.get("FetchPatientDataTimeStampList", []) + client_result["total_patients"] = len(patient_list) + result["total_patients"] += len(patient_list) - # Process each patient - for patient_data in patients: - try: - # Ensure patient data is in proper HIS format - if isinstance(patient_data, dict): - if "FetchPatientDataTimeStampList" not in patient_data: - # Wrap in proper format if needed - patient_data = { - "FetchPatientDataTimeStampList": [patient_data], - "FetchPatientDataTimeStampVisitDataList": [], - "Code": 200, - "Status": "Success", - } + if not patient_list: + logger.info(f"No patients found in {client_result['config']}") + result["details"].append(client_result) + continue - # Process using HISAdapter - process_result = HISAdapter.process_his_data(patient_data) + logger.info(f"Fetched {len(patient_list)} patients from {client_result['config']}") - if process_result["success"]: - client_result["surveys_created"] += 1 - result["surveys_created"] += 1 + process_result = HISAdapter.process_his_response(his_data) - if process_result.get("survey_sent"): - client_result["surveys_sent"] += 1 - result["surveys_sent"] += 1 - else: - error_msg = f"Failed to process patient: {process_result.get('message', 'Unknown error')}" - logger.warning(error_msg) - client_result["errors"].append(error_msg) + client_result["visits_saved"] = process_result.get("visits_saved", 0) + client_result["surveys_created"] = process_result.get("surveys_created", 0) + client_result["errors"] = process_result.get("errors", []) - except Exception as e: - error_msg = f"Error processing patient data: {str(e)}" - logger.error(error_msg, exc_info=True) - client_result["errors"].append(error_msg) + result["visits_saved"] += client_result["visits_saved"] + result["surveys_created"] += client_result["surveys_created"] + result["errors"].extend(client_result["errors"]) - # Update last sync timestamp if client.config: client.config.last_sync_at = timezone.now() client.config.save(update_fields=["last_sync_at"]) @@ -467,10 +348,10 @@ def fetch_his_surveys(): result["success"] = True logger.info( - f"HIS survey fetch completed: {result['clients_processed']} clients, " - f"{result['patients_fetched']} patients, " - f"{result['surveys_created']} surveys created, " - f"{result['surveys_sent']} surveys sent" + f"HIS fetch completed: {result['clients_processed']} clients, " + f"{result['total_patients']} patients, " + f"{result['visits_saved']} visits saved, " + f"{result['surveys_created']} surveys created" ) except Exception as e: diff --git a/apps/integrations/urls.py b/apps/integrations/urls.py index a9e2ef7..da193b4 100644 --- a/apps/integrations/urls.py +++ b/apps/integrations/urls.py @@ -7,23 +7,27 @@ from .views import ( HISPatientDataView, InboundEventViewSet, IntegrationConfigViewSet, + SimulateHISPayloadView, + TestHISDataView, ) -app_name = 'integrations' +app_name = "integrations" router = DefaultRouter() -router.register(r'configs', IntegrationConfigViewSet, basename='integration-config') -router.register(r'mappings', EventMappingViewSet, basename='event-mapping') -router.register(r'legacy-events', InboundEventViewSet, basename='inbound-event') -router.register(r'survey-template-mappings', SurveyTemplateMappingViewSet, basename='survey-template-mapping') +router.register(r"configs", IntegrationConfigViewSet, basename="integration-config") +router.register(r"mappings", EventMappingViewSet, basename="event-mapping") +router.register(r"legacy-events", InboundEventViewSet, basename="inbound-event") +router.register(r"survey-template-mappings", SurveyTemplateMappingViewSet, basename="survey-template-mapping") urlpatterns = [ # Survey template mapping settings page - path('settings/survey-mappings/', survey_mapping_settings, name='survey-mapping-settings'), - + path("settings/survey-mappings/", survey_mapping_settings, name="survey-mapping-settings"), # Main HIS integration endpoint - receives complete patient data - path('events/', HISPatientDataView.as_view(), name='his-patient-data'), - + path("events/", HISPatientDataView.as_view(), name="his-patient-data"), + # Test HIS data endpoint - mimics real HIS API from loaded test data + path("test-his-data/", TestHISDataView.as_view(), name="test-his-data"), + # Simulate HIS payload - POST data to test the full pipeline + path("simulate-his-payload/", SimulateHISPayloadView.as_view(), name="simulate-his-payload"), # Legacy event-based endpoint (deprecated, kept for backward compatibility) - path('', include(router.urls)), + path("", include(router.urls)), ] diff --git a/apps/integrations/views.py b/apps/integrations/views.py index 187bac2..73c690c 100644 --- a/apps/integrations/views.py +++ b/apps/integrations/views.py @@ -1,7 +1,9 @@ """ Integrations views and viewsets """ + import logging +from datetime import datetime from rest_framework import status, views, viewsets from rest_framework.decorators import action @@ -11,7 +13,7 @@ from rest_framework.response import Response from apps.accounts.permissions import IsPXAdmin from apps.core.services import AuditService -from .models import EventMapping, InboundEvent, IntegrationConfig +from .models import EventMapping, HISTestPatient, HISTestVisit, InboundEvent, IntegrationConfig from .serializers import ( EventMappingSerializer, HISPatientDataSerializer, @@ -19,20 +21,20 @@ from .serializers import ( ) from .services.his_adapter import HISAdapter -logger = logging.getLogger('apps.integrations') +logger = logging.getLogger("apps.integrations") class HISPatientDataView(views.APIView): """ API View for receiving complete HIS patient data. - + This replaces the old event-based approach. HIS systems send complete patient data including demographics and visit timeline. The system determines survey type from PatientType and sends appropriate survey via SMS. - + POST /api/integrations/events/ - Send complete HIS patient data - + Request Format: { "FetchPatientDataTimeStampList": [{...patient demographics...}], @@ -43,13 +45,13 @@ class HISPatientDataView(views.APIView): "Code": 200, "Status": "Success" } - + PatientType Codes: - "1" → Inpatient Survey - "2" or "O" → OPD Survey - "3" or "E" → EMS Survey - "4" or "D" → Day Case Survey - + Response Format: { "success": true, @@ -68,43 +70,37 @@ class HISPatientDataView(views.APIView): "survey_sent": true } """ + permission_classes = [] # Allow public access for HIS integration - + def post(self, request): """Process HIS patient data and create/send survey""" # Validate HIS data format serializer = HISPatientDataSerializer(data=request.data) - + if not serializer.is_valid(): return Response( - { - 'success': False, - 'error': 'Invalid HIS data format', - 'details': serializer.errors - }, - status=status.HTTP_400_BAD_REQUEST + {"success": False, "error": "Invalid HIS data format", "details": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, ) - + his_data = serializer.validated_data - + # Log the incoming data patient_list = his_data.get("FetchPatientDataTimeStampList", []) patient_data = patient_list[0] if patient_list else {} patient_id = patient_data.get("PatientID", "Unknown") patient_type = patient_data.get("PatientType", "Unknown") - - logger.info( - f"HIS patient data received: PatientID={patient_id}, " - f"PatientType={patient_type}" - ) - + + logger.info(f"HIS patient data received: PatientID={patient_id}, PatientType={patient_type}") + # Process HIS data using HISAdapter result = HISAdapter.process_his_data(his_data) - + # Create audit log - if result['success']: + if result["success"]: AuditService.log_from_request( - event_type='his_integration', + event_type="his_integration", description=( f"HIS patient data processed: PatientID={patient_id}, " f"PatientType={patient_type}, " @@ -112,145 +108,380 @@ class HISPatientDataView(views.APIView): ), request=request, metadata={ - 'patient_id': patient_id, - 'patient_type': patient_type, - 'survey_sent': result.get('survey_sent', False), - 'survey_id': result.get('survey').id if result.get('survey') else None - } + "patient_id": patient_id, + "patient_type": patient_type, + "survey_sent": result.get("survey_sent", False), + "survey_id": result.get("survey").id if result.get("survey") else None, + }, ) - + # Prepare response - response_data = { - 'success': result['success'], - 'message': result['message'] - } - + response_data = {"success": result["success"], "message": result["message"]} + # Add patient info if available - if result.get('patient'): - patient = result['patient'] - response_data['patient'] = { - 'id': patient.id, - 'mrn': patient.mrn, - 'name': f"{patient.first_name} {patient.last_name}".strip() + if result.get("patient"): + patient = result["patient"] + response_data["patient"] = { + "id": patient.id, + "mrn": patient.mrn, + "name": f"{patient.first_name} {patient.last_name}".strip(), } - response_data['patient_type'] = result.get('patient_type') - + response_data["patient_type"] = result.get("patient_type") + # Add survey info if available - if result.get('survey'): - survey = result['survey'] - response_data['survey'] = { - 'id': survey.id, - 'status': survey.status, - 'survey_url': survey.get_survey_url() - } - response_data['survey_sent'] = result.get('survey_sent', False) - + if result.get("survey"): + survey = result["survey"] + response_data["survey"] = {"id": survey.id, "status": survey.status, "survey_url": survey.get_survey_url()} + response_data["survey_sent"] = result.get("survey_sent", False) + # Return appropriate status code - if result['success']: + if result["success"]: status_code = status.HTTP_200_OK else: status_code = status.HTTP_400_BAD_REQUEST - + return Response(response_data, status=status_code) class InboundEventViewSet(viewsets.ModelViewSet): """ Legacy ViewSet for Inbound Events (DEPRECATED). - + This viewset is kept for backward compatibility but is no longer the primary integration method. Use HISPatientDataView instead. """ + queryset = InboundEvent.objects.all() permission_classes = [IsAuthenticated, IsPXAdmin] - filterset_fields = ['status', 'source_system', 'event_code', 'encounter_id'] - search_fields = ['encounter_id', 'patient_identifier', 'event_code'] - ordering_fields = ['received_at', 'processed_at'] - ordering = ['-received_at'] - + filterset_fields = ["status", "source_system", "event_code", "encounter_id"] + search_fields = ["encounter_id", "patient_identifier", "event_code"] + ordering_fields = ["received_at", "processed_at"] + ordering = ["-received_at"] + def get_serializer_class(self): """Return appropriate serializer based on action""" from .serializers import ( InboundEventSerializer, InboundEventListSerializer, ) - if self.action == 'list': + + if self.action == "list": return InboundEventListSerializer return InboundEventSerializer - - @action(detail=False, methods=['post'], permission_classes=[IsPXAdmin]) + + @action(detail=False, methods=["post"], permission_classes=[IsPXAdmin]) def bulk_create(self, request): """ Bulk create events. - + DEPRECATED: This endpoint is kept for backward compatibility. Use HISPatientDataView for new integrations. """ from .serializers import InboundEventCreateSerializer - - events_data = request.data.get('events', []) - + + events_data = request.data.get("events", []) + if not events_data: - return Response( - {'error': 'No events provided'}, - status=status.HTTP_400_BAD_REQUEST - ) - + return Response({"error": "No events provided"}, status=status.HTTP_400_BAD_REQUEST) + created_events = [] errors = [] - + for event_data in events_data: serializer = InboundEventCreateSerializer(data=event_data) if serializer.is_valid(): event = serializer.save() created_events.append(event) else: - errors.append({ - 'data': event_data, - 'errors': serializer.errors - }) - - return Response({ - 'created': len(created_events), - 'failed': len(errors), - 'errors': errors, - 'message': 'This endpoint is deprecated. Use HISPatientDataView for new integrations.' - }, status=status.HTTP_201_CREATED if created_events else status.HTTP_400_BAD_REQUEST) + errors.append({"data": event_data, "errors": serializer.errors}) + + return Response( + { + "created": len(created_events), + "failed": len(errors), + "errors": errors, + "message": "This endpoint is deprecated. Use HISPatientDataView for new integrations.", + }, + status=status.HTTP_201_CREATED if created_events else status.HTTP_400_BAD_REQUEST, + ) class IntegrationConfigViewSet(viewsets.ModelViewSet): """ ViewSet for Integration Configurations. - + Permissions: - Only PX Admins can manage integration configurations """ + queryset = IntegrationConfig.objects.all() serializer_class = IntegrationConfigSerializer permission_classes = [IsPXAdmin] - filterset_fields = ['source_system', 'is_active'] - search_fields = ['name', 'description'] - ordering_fields = ['name', 'created_at'] - ordering = ['name'] - + filterset_fields = ["source_system", "is_active"] + search_fields = ["name", "description"] + ordering_fields = ["name", "created_at"] + ordering = ["name"] + def get_queryset(self): - return super().get_queryset().prefetch_related('event_mappings') + return super().get_queryset().prefetch_related("event_mappings") class EventMappingViewSet(viewsets.ModelViewSet): """ ViewSet for Event Mappings. - + Permissions: - Only PX Admins can manage event mappings """ + queryset = EventMapping.objects.all() serializer_class = EventMappingSerializer permission_classes = [IsPXAdmin] - filterset_fields = ['integration_config', 'is_active'] - search_fields = ['external_event_code', 'internal_event_code'] - ordering_fields = ['external_event_code'] - ordering = ['integration_config', 'external_event_code'] - + filterset_fields = ["integration_config", "is_active"] + search_fields = ["external_event_code", "internal_event_code"] + ordering_fields = ["external_event_code"] + ordering = ["integration_config", "external_event_code"] + def get_queryset(self): - return super().get_queryset().select_related('integration_config') \ No newline at end of file + return super().get_queryset().select_related("integration_config") + + +class TestHISDataView(views.APIView): + """ + Test endpoint that mimics the real HIS API. + + GET /api/integrations/test-his-data/?FromDate=...&ToDate=...&SSN=...&MobileNo=... + + Reads from HISTestPatient/HISTestVisit tables (loaded via load_his_test_data command). + Supports the same query parameters as the real HIS API: + - FromDate / ToDate: Filter by AdmitDate range (format: DD-Mon-YYYY HH:MM:SS) + - SSN: Filter by SSN ("0" = all) + - MobileNo: Filter by MobileNo ("0" = all) + """ + + permission_classes = [] + + def _parse_his_date(self, date_str): + if not date_str: + return None + try: + from django.utils import timezone + + naive = datetime.strptime(date_str, "%d-%b-%Y %H:%M:%S") + return timezone.make_aware(naive) + except ValueError: + pass + try: + from django.utils import timezone + + naive = datetime.strptime(date_str, "%d-%b-%Y %H:%M") + return timezone.make_aware(naive) + except ValueError: + return None + + def get(self, request): + from_date_str = request.GET.get("FromDate") + to_date_str = request.GET.get("ToDate") + ssn = request.GET.get("SSN", "0") + mobile_no = request.GET.get("MobileNo", "0") + + from_date = self._parse_his_date(from_date_str) + to_date = self._parse_his_date(to_date_str) + + if from_date and to_date and to_date < from_date: + return Response( + {"Code": 400, "Status": "Error", "Message": "ToDate must be after FromDate"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + qs = HISTestPatient.objects.all() + + if from_date: + qs = qs.filter(admit_date__gte=from_date) + + if to_date: + qs = qs.filter(admit_date__lte=to_date) + + if ssn and ssn != "0": + qs = qs.filter(ssn=ssn) + + if mobile_no and mobile_no != "0": + qs = qs.filter(mobile_no=mobile_no) + + patients = list(qs[:1000]) + admission_ids = [p.admission_id for p in patients] + + patient_list = [p.patient_data for p in patients] + + visits = HISTestVisit.objects.filter(admission_id__in=admission_ids) + ed_visits = [] + ip_visits = [] + op_visits = [] + for v in visits: + if v.visit_category == "ED": + ed_visits.append(v.visit_data) + elif v.visit_category == "IP": + ip_visits.append(v.visit_data) + elif v.visit_category == "OP": + op_visits.append(v.visit_data) + + return Response( + { + "FetchPatientDataTimeStampList": patient_list, + "FetchPatientDataTimeStampVisitEDDataList": ed_visits, + "FetchPatientDataTimeStampVisitIPDataList": ip_visits, + "FetchPatientDataTimeStampVisitOPDataList": op_visits, + "Code": 200, + "Status": "Success", + "MobileNo": "", + "Message": "", + "Message2L": "", + } + ) + + +class SimulateHISPayloadView(views.APIView): + """ + POST endpoint to simulate receiving HIS data for testing. + + Accepts the same JSON format as the real HIS API response and: + 1. Stores patients/visits into HISTestPatient/HISTestVisit (persistent) + 2. Calls HISAdapter.process_his_response() to process everything + 3. Returns full processing results (surveys created, errors, etc.) + + POST /api/integrations/simulate-his-payload/ + Body: same as HIS response (visit_data.json format) + + After posting, the data will also be picked up by the regular + fetch-his-surveys cron if the "HIS Test" IntegrationConfig is active. + """ + + permission_classes = [IsAuthenticated, IsPXAdmin] + + def _parse_date(self, date_str): + if not date_str: + return None + try: + naive = datetime.strptime(date_str, "%d-%b-%Y %H:%M") + return timezone.make_aware(naive) + except ValueError: + pass + try: + naive = datetime.strptime(date_str, "%d-%b-%Y %H:%M:%S") + return timezone.make_aware(naive) + except ValueError: + return None + + def post(self, request): + payload = request.data + if not isinstance(payload, dict): + return Response({"detail": "Request body must be a JSON object"}, status=status.HTTP_400_BAD_REQUEST) + + patient_list = payload.get("FetchPatientDataTimeStampList", []) + if not patient_list: + return Response({"detail": "FetchPatientDataTimeStampList is empty"}, status=status.HTTP_400_BAD_REQUEST) + + ed_visits = payload.get("FetchPatientDataTimeStampVisitEDDataList", []) + ip_visits = payload.get("FetchPatientDataTimeStampVisitIPDataList", []) + op_visits = payload.get("FetchPatientDataTimeStampVisitOPDataList", []) + + admission_ids = [p.get("AdmissionID") for p in patient_list] + admission_id_set = {aid for aid in admission_ids if aid} + + patients_stored = 0 + visits_stored = 0 + skipped_patients = 0 + + existing_patients = set( + HISTestPatient.objects.filter(admission_id__in=admission_id_set).values_list("admission_id", flat=True) + ) + existing_visits = set( + HISTestVisit.objects.filter(admission_id__in=admission_id_set).values_list( + "admission_id", "event_type", "bill_date" + ) + ) + + patient_batch = [] + for p in patient_list: + aid = p.get("AdmissionID", "") + if aid in existing_patients: + skipped_patients += 1 + continue + + patient_batch.append( + HISTestPatient( + admission_id=aid, + patient_id=str(p.get("PatientID", "")), + patient_type=p.get("PatientType", ""), + reg_code=p.get("RegCode", ""), + ssn=p.get("SSN", ""), + mobile_no=p.get("MobileNo", ""), + admit_date=self._parse_date(p.get("AdmitDate")), + discharge_date=self._parse_date(p.get("DischargeDate")), + patient_data=p, + hospital_id=str(p.get("HospitalID", "")), + hospital_name=p.get("HospitalName", ""), + patient_name=p.get("PatientName", ""), + ) + ) + + if patient_batch: + HISTestPatient.objects.bulk_create(patient_batch, batch_size=1000) + patients_stored = len(patient_batch) + + visit_batch = [] + for v in ed_visits + ip_visits + op_visits: + aid = v.get("AdmissionID", "") + event_type = v.get("Type", "") + bill_date = self._parse_date(v.get("BillDate")) + visit_key = (aid, event_type, bill_date) + if visit_key in existing_visits: + continue + + visit_category = "ED" + pt = v.get("PatientType", "") + if pt == "IP": + visit_category = "IP" + elif pt in ("OP", "OPD"): + visit_category = "OP" + + visit_batch.append( + HISTestVisit( + admission_id=aid, + patient_id=str(v.get("PatientID", "")), + visit_category=visit_category, + event_type=event_type, + bill_date=bill_date, + reg_code=v.get("RegCode", ""), + ssn=v.get("SSN", ""), + mobile_no=v.get("MobileNo", ""), + visit_data=v, + ) + ) + + if visit_batch: + HISTestVisit.objects.bulk_create(visit_batch, batch_size=2000) + visits_stored = len(visit_batch) + + his_data = { + "FetchPatientDataTimeStampList": patient_list, + "FetchPatientDataTimeStampVisitEDDataList": ed_visits, + "FetchPatientDataTimeStampVisitIPDataList": ip_visits, + "FetchPatientDataTimeStampVisitOPDataList": op_visits, + "Code": payload.get("Code", 200), + "Status": payload.get("Status", "Success"), + "Message": payload.get("Message", ""), + } + + process_result = HISAdapter.process_his_response(his_data) + + return Response( + { + "storage": { + "patients_stored": patients_stored, + "patients_skipped": skipped_patients, + "visits_stored": visits_stored, + }, + "processing": process_result, + } + ) diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py index bec49ea..02e50e1 100644 --- a/apps/notifications/admin.py +++ b/apps/notifications/admin.py @@ -1,214 +1,302 @@ """ Notifications admin """ + from django.contrib import admin from django.utils.html import format_html -from .models import NotificationLog, NotificationTemplate +from .models import NotificationLog, NotificationTemplate, UserNotification from .settings_models import HospitalNotificationSettings, NotificationSettingsLog @admin.register(NotificationLog) class NotificationLogAdmin(admin.ModelAdmin): """Notification log admin""" + list_display = [ - 'channel', 'recipient', 'subject_preview', - 'status_badge', 'provider', 'retry_count', - 'sent_at', 'created_at' + "channel", + "recipient", + "subject_preview", + "status_badge", + "provider", + "retry_count", + "sent_at", + "created_at", ] - list_filter = ['channel', 'status', 'provider', 'created_at', 'sent_at'] - search_fields = ['recipient', 'subject', 'message', 'provider_message_id'] - ordering = ['-created_at'] - date_hierarchy = 'created_at' - + list_filter = ["channel", "status", "provider", "created_at", "sent_at"] + search_fields = ["recipient", "subject", "message", "provider_message_id"] + ordering = ["-created_at"] + date_hierarchy = "created_at" + fieldsets = ( - ('Delivery', { - 'fields': ('channel', 'recipient', 'subject', 'message') - }), - ('Status', { - 'fields': ('status', 'sent_at', 'delivered_at', 'error', 'retry_count') - }), - ('Provider', { - 'fields': ('provider', 'provider_message_id', 'provider_response'), - 'classes': ('collapse',) - }), - ('Related Object', { - 'fields': ('content_type', 'object_id'), - 'classes': ('collapse',) - }), - ('Metadata', { - 'fields': ('metadata', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ("Delivery", {"fields": ("channel", "recipient", "subject", "message")}), + ("Status", {"fields": ("status", "sent_at", "delivered_at", "error", "retry_count")}), + ("Provider", {"fields": ("provider", "provider_message_id", "provider_response"), "classes": ("collapse",)}), + ("Related Object", {"fields": ("content_type", "object_id"), "classes": ("collapse",)}), + ("Metadata", {"fields": ("metadata", "created_at", "updated_at"), "classes": ("collapse",)}), ) - + readonly_fields = [ - 'sent_at', 'delivered_at', 'provider_message_id', - 'provider_response', 'created_at', 'updated_at' + "sent_at", + "delivered_at", + "provider_message_id", + "provider_response", + "created_at", + "updated_at", ] - + def has_add_permission(self, request): # Notifications should only be created programmatically return False - + def has_delete_permission(self, request, obj=None): # Keep notification logs for compliance return False - + def subject_preview(self, obj): """Show preview of subject""" if obj.subject: - return obj.subject[:50] + '...' if len(obj.subject) > 50 else obj.subject - return '-' - subject_preview.short_description = 'Subject' - + return obj.subject[:50] + "..." if len(obj.subject) > 50 else obj.subject + return "-" + + subject_preview.short_description = "Subject" + def status_badge(self, obj): """Display status with color badge""" colors = { - 'pending': 'warning', - 'sending': 'info', - 'sent': 'success', - 'delivered': 'success', - 'failed': 'danger', - 'bounced': 'danger', + "pending": "warning", + "sending": "info", + "sent": "success", + "delivered": "success", + "failed": "danger", + "bounced": "danger", } - color = colors.get(obj.status, 'secondary') - return format_html( - '{}', - color, - obj.get_status_display() - ) - status_badge.short_description = 'Status' + color = colors.get(obj.status, "secondary") + return format_html('{}', color, obj.get_status_display()) + + status_badge.short_description = "Status" @admin.register(NotificationTemplate) class NotificationTemplateAdmin(admin.ModelAdmin): """Notification template admin""" - list_display = ['name', 'template_type', 'is_active', 'created_at'] - list_filter = ['template_type', 'is_active'] - search_fields = ['name', 'description'] - ordering = ['name'] - + + list_display = ["name", "template_type", "is_active", "created_at"] + list_filter = ["template_type", "is_active"] + search_fields = ["name", "description"] + ordering = ["name"] + fieldsets = ( - (None, { - 'fields': ('name', 'template_type', 'description') - }), - ('SMS Templates', { - 'fields': ('sms_template', 'sms_template_ar'), - 'classes': ('collapse',) - }), - ('WhatsApp Templates', { - 'fields': ('whatsapp_template', 'whatsapp_template_ar'), - 'classes': ('collapse',) - }), - ('Email Templates', { - 'fields': ( - 'email_subject', 'email_subject_ar', - 'email_template', 'email_template_ar' - ), - 'classes': ('collapse',) - }), - ('Status', { - 'fields': ('is_active',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("name", "template_type", "description")}), + ("SMS Templates", {"fields": ("sms_template", "sms_template_ar"), "classes": ("collapse",)}), + ("WhatsApp Templates", {"fields": ("whatsapp_template", "whatsapp_template_ar"), "classes": ("collapse",)}), + ( + "Email Templates", + { + "fields": ("email_subject", "email_subject_ar", "email_template", "email_template_ar"), + "classes": ("collapse",), + }, + ), + ("Status", {"fields": ("is_active",)}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] + + readonly_fields = ["created_at", "updated_at"] @admin.register(HospitalNotificationSettings) class HospitalNotificationSettingsAdmin(admin.ModelAdmin): """Hospital notification settings admin""" - list_display = ['hospital', 'notifications_enabled', 'updated_at'] - list_filter = ['notifications_enabled', 'updated_at'] - search_fields = ['hospital__name'] - + + list_display = ["hospital", "notifications_enabled", "updated_at"] + list_filter = ["notifications_enabled", "updated_at"] + search_fields = ["hospital__name"] + fieldsets = ( - ('Hospital', { - 'fields': ('hospital',) - }), - ('Master Control', { - 'fields': ('notifications_enabled',) - }), - ('Complaint Notifications', { - 'fields': ( - 'complaint_acknowledgment_email', 'complaint_acknowledgment_sms', 'complaint_acknowledgment_whatsapp', - 'complaint_assigned_email', 'complaint_assigned_sms', 'complaint_assigned_whatsapp', - 'complaint_status_changed_email', 'complaint_status_changed_sms', 'complaint_status_changed_whatsapp', - 'complaint_resolved_email', 'complaint_resolved_sms', 'complaint_resolved_whatsapp', - 'complaint_closed_email', 'complaint_closed_sms', 'complaint_closed_whatsapp', - ), - 'classes': ('collapse',) - }), - ('Explanation Notifications', { - 'fields': ( - 'explanation_requested_email', 'explanation_requested_sms', 'explanation_requested_whatsapp', - 'explanation_reminder_email', 'explanation_reminder_sms', 'explanation_reminder_whatsapp', - 'explanation_overdue_email', 'explanation_overdue_sms', 'explanation_overdue_whatsapp', - 'explanation_received_email', 'explanation_received_sms', 'explanation_received_whatsapp', - 'explanation_manager_cc', - ), - 'classes': ('collapse',) - }), - ('Survey Notifications', { - 'fields': ( - 'survey_invitation_email', 'survey_invitation_sms', 'survey_invitation_whatsapp', - 'survey_reminder_email', 'survey_reminder_sms', 'survey_reminder_whatsapp', - 'survey_completed_email', 'survey_completed_sms', - ), - 'classes': ('collapse',) - }), - ('Action Notifications', { - 'fields': ( - 'action_assigned_email', 'action_assigned_sms', 'action_assigned_whatsapp', - 'action_due_soon_email', 'action_due_soon_sms', - 'action_overdue_email', 'action_overdue_sms', - ), - 'classes': ('collapse',) - }), - ('SLA Notifications', { - 'fields': ( - 'sla_reminder_email', 'sla_reminder_sms', - 'sla_breach_email', 'sla_breach_sms', 'sla_breach_whatsapp', - ), - 'classes': ('collapse',) - }), - ('Onboarding Notifications', { - 'fields': ( - 'onboarding_invitation_email', 'onboarding_invitation_sms', 'onboarding_invitation_whatsapp', - 'onboarding_reminder_email', 'onboarding_reminder_sms', 'onboarding_reminder_whatsapp', - 'onboarding_completion_email', 'onboarding_completion_sms', - ), - 'classes': ('collapse',) - }), - ('Quiet Hours', { - 'fields': ('quiet_hours_enabled', 'quiet_hours_start', 'quiet_hours_end'), - }), - ('Retry Settings', { - 'fields': ('retry_failed_notifications', 'max_retries'), - }), + ("Hospital", {"fields": ("hospital",)}), + ("Master Control", {"fields": ("notifications_enabled",)}), + ( + "Complaint Notifications", + { + "fields": ( + "complaint_acknowledgment_email", + "complaint_acknowledgment_sms", + "complaint_acknowledgment_whatsapp", + "complaint_assigned_email", + "complaint_assigned_sms", + "complaint_assigned_whatsapp", + "complaint_status_changed_email", + "complaint_status_changed_sms", + "complaint_status_changed_whatsapp", + "complaint_resolved_email", + "complaint_resolved_sms", + "complaint_resolved_whatsapp", + "complaint_closed_email", + "complaint_closed_sms", + "complaint_closed_whatsapp", + ), + "classes": ("collapse",), + }, + ), + ( + "Explanation Notifications", + { + "fields": ( + "explanation_requested_email", + "explanation_requested_sms", + "explanation_requested_whatsapp", + "explanation_reminder_email", + "explanation_reminder_sms", + "explanation_reminder_whatsapp", + "explanation_overdue_email", + "explanation_overdue_sms", + "explanation_overdue_whatsapp", + "explanation_received_email", + "explanation_received_sms", + "explanation_received_whatsapp", + "explanation_manager_cc", + ), + "classes": ("collapse",), + }, + ), + ( + "Survey Notifications", + { + "fields": ( + "survey_invitation_email", + "survey_invitation_sms", + "survey_invitation_whatsapp", + "survey_reminder_email", + "survey_reminder_sms", + "survey_reminder_whatsapp", + "survey_completed_email", + "survey_completed_sms", + ), + "classes": ("collapse",), + }, + ), + ( + "Action Notifications", + { + "fields": ( + "action_assigned_email", + "action_assigned_sms", + "action_assigned_whatsapp", + "action_due_soon_email", + "action_due_soon_sms", + "action_overdue_email", + "action_overdue_sms", + ), + "classes": ("collapse",), + }, + ), + ( + "SLA Notifications", + { + "fields": ( + "sla_reminder_email", + "sla_reminder_sms", + "sla_breach_email", + "sla_breach_sms", + "sla_breach_whatsapp", + ), + "classes": ("collapse",), + }, + ), + ( + "Onboarding Notifications", + { + "fields": ( + "onboarding_invitation_email", + "onboarding_invitation_sms", + "onboarding_invitation_whatsapp", + "onboarding_reminder_email", + "onboarding_reminder_sms", + "onboarding_reminder_whatsapp", + "onboarding_completion_email", + "onboarding_completion_sms", + ), + "classes": ("collapse",), + }, + ), + ( + "Quiet Hours", + { + "fields": ("quiet_hours_enabled", "quiet_hours_start", "quiet_hours_end"), + }, + ), + ( + "Retry Settings", + { + "fields": ("retry_failed_notifications", "max_retries"), + }, + ), ) - - readonly_fields = ['created_at', 'updated_at'] + + readonly_fields = ["created_at", "updated_at"] @admin.register(NotificationSettingsLog) class NotificationSettingsLogAdmin(admin.ModelAdmin): """Notification settings change log admin""" - list_display = ['hospital', 'field_name', 'old_value', 'new_value', 'changed_by', 'created_at'] - list_filter = ['hospital', 'created_at'] - search_fields = ['field_name', 'changed_by__email', 'changed_by__first_name', 'changed_by__last_name'] - ordering = ['-created_at'] - readonly_fields = ['hospital', 'changed_by', 'field_name', 'old_value', 'new_value', 'created_at'] - + + list_display = ["hospital", "field_name", "old_value", "new_value", "changed_by", "created_at"] + list_filter = ["hospital", "created_at"] + search_fields = ["field_name", "changed_by__email", "changed_by__first_name", "changed_by__last_name"] + ordering = ["-created_at"] + readonly_fields = ["hospital", "changed_by", "field_name", "old_value", "new_value", "created_at"] + def has_add_permission(self, request): return False - + def has_change_permission(self, request, obj=None): return False - + def has_delete_permission(self, request, obj=None): return False + + +@admin.register(UserNotification) +class UserNotificationAdmin(admin.ModelAdmin): + """User notification admin""" + + list_display = ["user", "notification_type", "title_preview", "is_read", "is_dismissed", "created_at"] + list_filter = ["notification_type", "is_read", "is_dismissed", "created_at"] + search_fields = ["user__email", "title", "message"] + ordering = ["-created_at"] + date_hierarchy = "created_at" + + fieldsets = ( + ("User", {"fields": ("user",)}), + ("Content", {"fields": ("title", "title_ar", "message", "message_ar")}), + ("Type & Status", {"fields": ("notification_type", "is_read", "read_at", "is_dismissed", "dismissed_at")}), + ("Links", {"fields": ("action_url", "email_log"), "classes": ("collapse",)}), + ("Related Object", {"fields": ("content_type", "object_id"), "classes": ("collapse",)}), + ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), + ) + + readonly_fields = ["created_at", "updated_at", "read_at", "dismissed_at"] + + def title_preview(self, obj): + """Short title preview""" + return obj.title[:50] + "..." if len(obj.title) > 50 else obj.title + + title_preview.short_description = "Title" + + actions = ["mark_as_read", "mark_as_dismissed"] + + def mark_as_read(self, request, queryset): + """Mark selected notifications as read""" + for notification in queryset: + notification.mark_as_read() + self.message_user(request, f"{queryset.count()} notifications marked as read") + + mark_as_read.short_description = "Mark selected as read" + + def mark_as_dismissed(self, request, queryset): + """Mark selected notifications as dismissed""" + for notification in queryset: + notification.mark_as_dismissed() + self.message_user(request, f"{queryset.count()} notifications dismissed") + + mark_as_dismissed.short_description = "Dismiss selected" diff --git a/apps/notifications/management/commands/cleanup_notifications.py b/apps/notifications/management/commands/cleanup_notifications.py new file mode 100644 index 0000000..678abd1 --- /dev/null +++ b/apps/notifications/management/commands/cleanup_notifications.py @@ -0,0 +1,55 @@ +""" +Management command to cleanup old notifications. + +Deletes notifications older than 30 days (configurable). +Run via cron or celery beat daily. + +Usage: + python manage.py cleanup_notifications + python manage.py cleanup_notifications --days=30 + python manage.py cleanup_notifications --dry-run +""" + +from django.core.management.base import BaseCommand +from django.utils import timezone +from datetime import timedelta + +from apps.notifications.models import UserNotification + + +class Command(BaseCommand): + help = "Cleanup old notifications (default: 30 days)" + + def add_arguments(self, parser): + parser.add_argument("--days", type=int, default=30, help="Number of days to keep notifications (default: 30)") + parser.add_argument( + "--dry-run", action="store_true", help="Show what would be deleted without actually deleting" + ) + + def handle(self, *args, **options): + days = options["days"] + dry_run = options["dry_run"] + + cutoff_date = timezone.now() - timedelta(days=days) + + # Get notifications to delete (both read and unread, older than cutoff) + notifications_to_delete = UserNotification.objects.filter(created_at__lt=cutoff_date) + + count = notifications_to_delete.count() + + if dry_run: + self.stdout.write( + self.style.WARNING(f"[DRY RUN] Would delete {count} notifications older than {days} days") + ) + # Show sample of what would be deleted + sample = notifications_to_delete[:5] + for n in sample: + self.stdout.write(f" - {n.title} ({n.created_at})") + if count > 5: + self.stdout.write(f" ... and {count - 5} more") + else: + if count > 0: + notifications_to_delete.delete() + self.stdout.write(self.style.SUCCESS(f"Deleted {count} notifications older than {days} days")) + else: + self.stdout.write(self.style.SUCCESS("No notifications to delete")) diff --git a/apps/notifications/management/commands/test_sms.py b/apps/notifications/management/commands/test_sms.py new file mode 100644 index 0000000..9687444 --- /dev/null +++ b/apps/notifications/management/commands/test_sms.py @@ -0,0 +1,35 @@ +from django.core.management.base import BaseCommand + +from apps.notifications.services import NotificationService + + +class Command(BaseCommand): + help = "Send a test SMS to a phone number" + + def add_arguments(self, parser): + parser.add_argument("phone", type=str, help="Phone number in international format (e.g. 966501234567)") + parser.add_argument("--message", type=str, default="Test SMS from PX360", help="Custom message text") + + def handle(self, *args, **options): + phone = options["phone"] + message = options["message"] + + self.stdout.write(f"Sending SMS to {phone}...") + self.stdout.write(f"Message: {message}") + self.stdout.write("") + + log = NotificationService.send_sms(phone, message) + + self.stdout.write(f"Status: {log.status}") + self.stdout.write(f"Provider: {log.provider}") + if log.error: + self.stdout.write(self.style.ERROR(f"Error: {log.error}")) + if log.provider_response: + self.stdout.write(f"Provider Response: {log.provider_response}") + + if log.status == "sent": + self.stdout.write(self.style.SUCCESS(f"SMS sent successfully to {phone}")) + elif log.status == "failed": + self.stdout.write(self.style.ERROR(f"SMS failed for {phone}")) + else: + self.stdout.write(self.style.WARNING(f"SMS status: {log.status} for {phone}")) diff --git a/apps/notifications/models.py b/apps/notifications/models.py index ebdcbfb..74b37bc 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -7,6 +7,7 @@ This module implements the notification system that: - Tracks delivery status - Supports retry logic """ + from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models @@ -16,26 +17,28 @@ from apps.core.models import BaseChoices, TimeStampedModel, UUIDModel class NotificationChannel(BaseChoices): """Notification channel choices""" - SMS = 'sms', 'SMS' - WHATSAPP = 'whatsapp', 'WhatsApp' - EMAIL = 'email', 'Email' - PUSH = 'push', 'Push Notification' + + SMS = "sms", "SMS" + WHATSAPP = "whatsapp", "WhatsApp" + EMAIL = "email", "Email" + PUSH = "push", "Push Notification" class NotificationStatus(BaseChoices): """Notification delivery status""" - PENDING = 'pending', 'Pending' - SENDING = 'sending', 'Sending' - SENT = 'sent', 'Sent' - DELIVERED = 'delivered', 'Delivered' - FAILED = 'failed', 'Failed' - BOUNCED = 'bounced', 'Bounced' + + PENDING = "pending", "Pending" + SENDING = "sending", "Sending" + SENT = "sent", "Sent" + DELIVERED = "delivered", "Delivered" + FAILED = "failed", "Failed" + BOUNCED = "bounced", "Bounced" class NotificationLog(UUIDModel, TimeStampedModel): """ Notification log - tracks all notification attempts. - + Logs every SMS, WhatsApp, and Email sent by the system. Used for: - Delivery tracking @@ -43,162 +46,222 @@ class NotificationLog(UUIDModel, TimeStampedModel): - Compliance - Analytics """ + # Channel and recipient - channel = models.CharField( - max_length=20, - choices=NotificationChannel.choices, - db_index=True - ) - recipient = models.CharField( - max_length=200, - help_text="Phone number or email address" - ) - + channel = models.CharField(max_length=20, choices=NotificationChannel.choices, db_index=True) + recipient = models.CharField(max_length=200, help_text="Phone number or email address") + # Message content subject = models.CharField(max_length=500, blank=True) message = models.TextField() - + # Related object (generic foreign key) - content_type = models.ForeignKey( - ContentType, - on_delete=models.SET_NULL, - null=True, - blank=True - ) + content_type = models.ForeignKey(ContentType, on_delete=models.SET_NULL, null=True, blank=True) object_id = models.UUIDField(null=True, blank=True) - content_object = GenericForeignKey('content_type', 'object_id') - + content_object = GenericForeignKey("content_type", "object_id") + # Delivery status status = models.CharField( - max_length=20, - choices=NotificationStatus.choices, - default=NotificationStatus.PENDING, - db_index=True + max_length=20, choices=NotificationStatus.choices, default=NotificationStatus.PENDING, db_index=True ) - + # Timestamps sent_at = models.DateTimeField(null=True, blank=True) delivered_at = models.DateTimeField(null=True, blank=True) - + # Provider response - provider = models.CharField( - max_length=50, - blank=True, - help_text="SMS/Email provider used" - ) - provider_message_id = models.CharField( - max_length=200, - blank=True, - help_text="Message ID from provider" - ) - provider_response = models.JSONField( - default=dict, - blank=True, - help_text="Full response from provider" - ) - + provider = models.CharField(max_length=50, blank=True, help_text="SMS/Email provider used") + provider_message_id = models.CharField(max_length=200, blank=True, help_text="Message ID from provider") + provider_response = models.JSONField(default=dict, blank=True, help_text="Full response from provider") + # Error tracking error = models.TextField(blank=True) retry_count = models.IntegerField(default=0) - + # Metadata - metadata = models.JSONField( - default=dict, - blank=True, - help_text="Additional metadata (campaign, template, etc.)" - ) - + metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata (campaign, template, etc.)") + class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] indexes = [ - models.Index(fields=['channel', 'status', '-created_at']), - models.Index(fields=['recipient', '-created_at']), - models.Index(fields=['content_type', 'object_id']), + models.Index(fields=["channel", "status", "-created_at"]), + models.Index(fields=["recipient", "-created_at"]), + models.Index(fields=["content_type", "object_id"]), ] - + def __str__(self): return f"{self.channel} to {self.recipient} ({self.status})" - + def mark_sent(self, provider_message_id=None): """Mark notification as sent""" from django.utils import timezone + self.status = NotificationStatus.SENT self.sent_at = timezone.now() if provider_message_id: self.provider_message_id = provider_message_id - self.save(update_fields=['status', 'sent_at', 'provider_message_id']) - + self.save(update_fields=["status", "sent_at", "provider_message_id"]) + def mark_delivered(self): """Mark notification as delivered""" from django.utils import timezone + self.status = NotificationStatus.DELIVERED self.delivered_at = timezone.now() - self.save(update_fields=['status', 'delivered_at']) - + self.save(update_fields=["status", "delivered_at"]) + def mark_failed(self, error_message): """Mark notification as failed""" self.status = NotificationStatus.FAILED self.error = error_message self.retry_count += 1 - self.save(update_fields=['status', 'error', 'retry_count']) + self.save(update_fields=["status", "error", "retry_count"]) class NotificationTemplate(UUIDModel, TimeStampedModel): """ Notification template for consistent messaging. - + Supports: - Bilingual templates (AR/EN) - Variable substitution - Multiple channels """ + name = models.CharField(max_length=200, unique=True) description = models.TextField(blank=True) - + # Template type template_type = models.CharField( max_length=50, choices=[ - ('survey_invitation', 'Survey Invitation'), - ('survey_reminder', 'Survey Reminder'), - ('complaint_acknowledgment', 'Complaint Acknowledgment'), - ('complaint_update', 'Complaint Update'), - ('action_assignment', 'Action Assignment'), - ('sla_reminder', 'SLA Reminder'), - ('sla_breach', 'SLA Breach'), - ('onboarding_invitation', 'Onboarding Invitation'), - ('onboarding_reminder', 'Onboarding Reminder'), - ('onboarding_completion', 'Onboarding Completion'), + ("survey_invitation", "Survey Invitation"), + ("survey_reminder", "Survey Reminder"), + ("complaint_acknowledgment", "Complaint Acknowledgment"), + ("complaint_update", "Complaint Update"), + ("action_assignment", "Action Assignment"), + ("sla_reminder", "SLA Reminder"), + ("sla_breach", "SLA Breach"), + ("onboarding_invitation", "Onboarding Invitation"), + ("onboarding_reminder", "Onboarding Reminder"), + ("onboarding_completion", "Onboarding Completion"), ], - db_index=True + db_index=True, ) - + # Channel-specific templates - sms_template = models.TextField( - blank=True, - help_text="SMS template with {{variables}}" - ) + sms_template = models.TextField(blank=True, help_text="SMS template with {{variables}}") sms_template_ar = models.TextField(blank=True) - - whatsapp_template = models.TextField( - blank=True, - help_text="WhatsApp template with {{variables}}" - ) + + whatsapp_template = models.TextField(blank=True, help_text="WhatsApp template with {{variables}}") whatsapp_template_ar = models.TextField(blank=True) - + email_subject = models.CharField(max_length=500, blank=True) email_subject_ar = models.CharField(max_length=500, blank=True) - email_template = models.TextField( - blank=True, - help_text="Email HTML template with {{variables}}" - ) + email_template = models.TextField(blank=True, help_text="Email HTML template with {{variables}}") email_template_ar = models.TextField(blank=True) - + # Configuration is_active = models.BooleanField(default=True) - + class Meta: - ordering = ['name'] - + ordering = ["name"] + def __str__(self): return self.name + + +class UserNotification(UUIDModel, TimeStampedModel): + """ + In-app notification for users - created for every email sent. + Auto-deleted after 30 days via management command. + """ + + user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="notifications") + + # Content (bilingual) + title = models.CharField(max_length=200) + title_ar = models.CharField(max_length=200, blank=True) + message = models.TextField() + message_ar = models.TextField(blank=True) + + # Type matching email/notification types + notification_type = models.CharField(max_length=50) + + # Related object (generic foreign key) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) + object_id = models.UUIDField(null=True, blank=True) + content_object = GenericForeignKey("content_type", "object_id") + + # Navigation + action_url = models.CharField(max_length=500, blank=True) + + # Status + is_read = models.BooleanField(default=False) + read_at = models.DateTimeField(null=True, blank=True) + is_dismissed = models.BooleanField(default=False) + dismissed_at = models.DateTimeField(null=True, blank=True) + + # Link to email log + email_log = models.ForeignKey( + NotificationLog, on_delete=models.SET_NULL, null=True, blank=True, related_name="user_notification" + ) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["user", "is_dismissed", "-created_at"]), + models.Index(fields=["user", "is_read", "-created_at"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self): + return f"{self.notification_type} for {self.user.email}" + + def mark_as_read(self): + """Mark notification as read""" + from django.utils import timezone + + self.is_read = True + self.read_at = timezone.now() + self.save(update_fields=["is_read", "read_at"]) + + def mark_as_dismissed(self): + """Mark notification as dismissed""" + from django.utils import timezone + + self.is_dismissed = True + self.dismissed_at = timezone.now() + self.save(update_fields=["is_dismissed", "dismissed_at"]) + + def get_title(self): + """Get title based on user language preference""" + from django.utils.translation import get_language + + if get_language() == "ar" and self.title_ar: + return self.title_ar + return self.title + + def get_message(self): + """Get message based on user language preference""" + from django.utils.translation import get_language + + if get_language() == "ar" and self.message_ar: + return self.message_ar + return self.message + + def get_icon(self): + """Get icon based on notification type""" + icons = { + "complaint_assigned": "user-check", + "complaint_update": "file-text", + "sla_reminder": "clock", + "sla_breach": "alert-triangle", + "action_required": "alert-circle", + "mention": "at-sign", + "survey_invitation": "clipboard", + "survey_reminder": "bell", + "onboarding_invitation": "user-plus", + "system": "bell", + } + return icons.get(self.notification_type, "bell") diff --git a/apps/notifications/services.py b/apps/notifications/services.py index 074fbe4..3deb190 100644 --- a/apps/notifications/services.py +++ b/apps/notifications/services.py @@ -7,6 +7,7 @@ via SMS, WhatsApp, and Email. For now, implements console backends that log to database. In production, integrate with actual providers (Twilio, WhatsApp Business API, SMTP). """ + import logging from django.conf import settings @@ -31,6 +32,12 @@ class NotificationService: """ Send SMS notification. + Routing order: + 1. External SMS API (if SMS_API_ENABLED) + 2. Mshastra (if SMS_PROVIDER=mshastra and credentials configured) + 3. Twilio (if SMS_PROVIDER=twilio and credentials configured) + 4. Console logger (fallback) + Args: phone: Recipient phone number message: SMS message text @@ -40,52 +47,163 @@ class NotificationService: Returns: NotificationLog instance """ - # Check if SMS API is enabled and use it (simulator or external API) - sms_api_config = settings.EXTERNAL_NOTIFICATION_API.get('sms', {}) - if sms_api_config.get('enabled', False): + sms_api_config = settings.EXTERNAL_NOTIFICATION_API.get("sms", {}) + if sms_api_config.get("enabled", False): return NotificationService.send_sms_via_api( - message=message, - phone=phone, - related_object=related_object, - metadata=metadata + message=message, phone=phone, related_object=related_object, metadata=metadata ) - # Create notification log + sms_config = settings.NOTIFICATION_CHANNELS.get("sms", {}) + provider = sms_config.get("provider", "console") + log = NotificationLog.objects.create( - channel='sms', + channel="sms", recipient=phone, message=message, content_object=related_object, - provider='console', # TODO: Replace with actual provider - metadata=metadata or {} + provider=provider, + metadata=metadata or {}, ) - # Check if SMS is enabled - sms_config = settings.NOTIFICATION_CHANNELS.get('sms', {}) - if not sms_config.get('enabled', False): + if provider == "mshastra": + return NotificationService._send_sms_mshastra(log, phone, message) + + if provider == "twilio": + return NotificationService._send_sms_twilio(log, phone, message) + + logger.info(f"[SMS Console] To: {phone} | Message: {message}") + log.mark_sent() + return log + + @staticmethod + def _send_sms_twilio(log, phone, message): + """ + Send SMS via Twilio SDK. + + Requires: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and + either TWILIO_PHONE_NUMBER or TWILIO_MESSAGING_SERVICE_SID. + """ + from twilio.base.exceptions import TwilioRestException + from twilio.rest import Client + + account_sid = settings.TWILIO_ACCOUNT_SID + auth_token = settings.TWILIO_AUTH_TOKEN + + if not account_sid or not auth_token: + logger.warning("Twilio credentials not configured, falling back to console") + log.provider = "console" + log.save(update_fields=["provider"]) logger.info(f"[SMS Console] To: {phone} | Message: {message}") log.mark_sent() return log - # TODO: Integrate with actual SMS provider (Twilio, etc.) - # Example: - # try: - # from twilio.rest import Client - # client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) - # message = client.messages.create( - # body=message, - # from_=settings.TWILIO_PHONE_NUMBER, - # to=phone - # ) - # log.mark_sent(provider_message_id=message.sid) - # except Exception as e: - # log.mark_failed(str(e)) + try: + client = Client(account_sid, auth_token) - # Console backend for now - logger.info(f"[SMS Console] To: {phone} | Message: {message}") - log.mark_sent() + twilio_kwargs = { + "body": message, + "to": phone, + } - return log + if settings.TWILIO_MESSAGING_SERVICE_SID: + twilio_kwargs["messaging_service_sid"] = settings.TWILIO_MESSAGING_SERVICE_SID + elif settings.TWILIO_PHONE_NUMBER: + twilio_kwargs["from_"] = settings.TWILIO_PHONE_NUMBER + else: + logger.warning("Twilio phone number or messaging service SID not configured, falling back to console") + log.provider = "console" + log.save(update_fields=["provider"]) + logger.info(f"[SMS Console] To: {phone} | Message: {message}") + log.mark_sent() + return log + + twilio_message = client.messages.create(**twilio_kwargs) + + log.mark_sent(provider_message_id=twilio_message.sid) + log.provider_response = { + "sid": twilio_message.sid, + "status": twilio_message.status, + "direction": twilio_message.direction, + "to": twilio_message.to, + } + log.save(update_fields=["provider_response"]) + + logger.info(f"SMS sent via Twilio to {phone}: sid={twilio_message.sid}") + return log + + except TwilioRestException as e: + logger.error(f"Twilio error sending SMS to {phone}: {e}") + log.mark_failed(str(e)) + return log + + except Exception as e: + logger.error(f"Unexpected error sending SMS via Twilio to {phone}: {e}", exc_info=True) + log.mark_failed(str(e)) + return log + + @staticmethod + def _send_sms_mshastra(log, phone, message): + """ + Send SMS via Mshastra API. + + Requires: MSHASTRA_USERNAME, MSHASTRA_PASSWORD, and MSHASTRA_SENDER_ID. + API: https://mshastra.com/sendurl.aspx + """ + import requests + + username = settings.MSHASTRA_USERNAME + password = settings.MSHASTRA_PASSWORD + sender_id = settings.MSHASTRA_SENDER_ID + + if not username or not password: + logger.warning("Mshastra credentials not configured, falling back to console") + log.provider = "console" + log.save(update_fields=["provider"]) + logger.info(f"[SMS Console] To: {phone} | Message: {message}") + log.mark_sent() + return log + + try: + url = "https://mshastra.com/sendurl.aspx" + params = { + "user": username, + "pwd": password, + "senderid": sender_id, + "mobileno": phone, + "msgtext": message, + "priority": "High", + "CountryCode": "ALL", + } + + response = requests.get(url, params=params, timeout=30) + response_text = response.text.strip() + + log.provider_response = {"status_code": response.status_code, "response": response_text} + log.save(update_fields=["provider_response"]) + + if "Send Successful" in response_text: + log.mark_sent() + logger.info(f"SMS sent via Mshastra to {phone}: {response_text}") + else: + log.mark_failed(response_text) + logger.warning(f"Mshastra SMS failed for {phone}: {response_text}") + + return log + + except requests.exceptions.Timeout: + logger.error(f"Mshastra API timeout for {phone}") + log.mark_failed("Request timeout") + return log + + except requests.exceptions.ConnectionError: + logger.error(f"Mshastra API connection error for {phone}") + log.mark_failed("Connection error") + return log + + except Exception as e: + logger.error(f"Unexpected error sending SMS via Mshastra to {phone}: {e}", exc_info=True) + log.mark_failed(str(e)) + return log @staticmethod def send_whatsapp(phone, message, related_object=None, metadata=None): @@ -103,17 +221,17 @@ class NotificationService: """ # Create notification log log = NotificationLog.objects.create( - channel='whatsapp', + channel="whatsapp", recipient=phone, message=message, content_object=related_object, - provider='console', # TODO: Replace with actual provider - metadata=metadata or {} + provider="console", # TODO: Replace with actual provider + metadata=metadata or {}, ) # Check if WhatsApp is enabled - whatsapp_config = settings.NOTIFICATION_CHANNELS.get('whatsapp', {}) - if not whatsapp_config.get('enabled', False): + whatsapp_config = settings.NOTIFICATION_CHANNELS.get("whatsapp", {}) + if not whatsapp_config.get("enabled", False): logger.info(f"[WhatsApp Console] To: {phone} | Message: {message}") log.mark_sent() return log @@ -141,9 +259,18 @@ class NotificationService: return log @staticmethod - def send_email(email, subject, message, html_message=None, related_object=None, metadata=None): + def send_email( + email, + subject, + message, + html_message=None, + related_object=None, + metadata=None, + notification_type="system", + user=None, + ): """ - Send Email notification. + Send Email notification and create corresponding in-app notification. Args: email: Recipient email address @@ -152,36 +279,50 @@ class NotificationService: html_message: Email message (HTML) (optional) related_object: Related model instance (optional) metadata: Additional metadata dict (optional) + notification_type: Type of notification (default: 'system') + user: User instance to create in-app notification for (optional, will try to lookup by email) Returns: NotificationLog instance """ + from apps.accounts.models import User + # Check if Email API is enabled and use it (simulator or external API) - email_api_config = settings.EXTERNAL_NOTIFICATION_API.get('email', {}) - if email_api_config.get('enabled', False): - return NotificationService.send_email_via_api( + email_api_config = settings.EXTERNAL_NOTIFICATION_API.get("email", {}) + if email_api_config.get("enabled", False): + log = NotificationService.send_email_via_api( message=message, email=email, subject=subject, html_message=html_message, related_object=related_object, - metadata=metadata + metadata=metadata, ) + # Create in-app notification after sending via API + NotificationService._create_in_app_notification_from_email( + log, email, subject, message, notification_type, related_object, user + ) + return log # Create notification log log = NotificationLog.objects.create( - channel='email', + channel="email", recipient=email, subject=subject, message=message, content_object=related_object, - provider='console', # TODO: Replace with actual provider - metadata=metadata or {} + provider="console", # TODO: Replace with actual provider + metadata=metadata or {}, + ) + + # Create in-app notification + NotificationService._create_in_app_notification_from_email( + log, email, subject, message, notification_type, related_object, user ) # Check if Email is enabled - email_config = settings.NOTIFICATION_CHANNELS.get('email', {}) - if not email_config.get('enabled', True): + email_config = settings.NOTIFICATION_CHANNELS.get("email", {}) + if not email_config.get("enabled", True): logger.info(f"[Email Console] To: {email} | Subject: {subject} | Message: {message}") log.mark_sent() return log @@ -194,7 +335,7 @@ class NotificationService: from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[email], html_message=html_message, - fail_silently=False + fail_silently=False, ) log.mark_sent() logger.info(f"Email sent to {email}: {subject}") @@ -204,6 +345,38 @@ class NotificationService: return log + @staticmethod + def _create_in_app_notification_from_email( + log, email, subject, message, notification_type, related_object=None, user=None + ): + """ + Create in-app notification from email data. + Private helper method. + """ + from apps.accounts.models import User + from .models import UserNotification + + # Try to find user if not provided + if not user: + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + # User not found, skip in-app notification + return None + + # Create in-app notification + notification = UserNotification.objects.create( + user=user, + title=subject, + message=message[:500], # Truncate for preview + notification_type=notification_type, + email_log=log, + content_object=related_object, + ) + + logger.info(f"In-app notification created for {user.email}: {subject}") + return notification + @staticmethod def send_email_via_api(message, email, subject, html_message=None, related_object=None, metadata=None): """ @@ -224,60 +397,55 @@ class NotificationService: import time # Check if enabled - email_config = settings.EXTERNAL_NOTIFICATION_API.get('email', {}) - if not email_config.get('enabled', False): + email_config = settings.EXTERNAL_NOTIFICATION_API.get("email", {}) + if not email_config.get("enabled", False): logger.warning("Email API is disabled. Skipping send_email_via_api") return None # Create notification log log = NotificationLog.objects.create( - channel='email', + channel="email", recipient=email, subject=subject, message=message, content_object=related_object, - provider='api', + provider="api", metadata={ - 'api_url': email_config.get('url'), - 'auth_method': email_config.get('auth_method'), - **(metadata or {}) - } + "api_url": email_config.get("url"), + "auth_method": email_config.get("auth_method"), + **(metadata or {}), + }, ) # Prepare request payload payload = { - 'to': email, - 'subject': subject, - 'message': message, + "to": email, + "subject": subject, + "message": message, } if html_message: - payload['html_message'] = html_message + payload["html_message"] = html_message # Prepare headers - headers = {'Content-Type': 'application/json'} - api_key = email_config.get('api_key', '') - auth_method = email_config.get('auth_method', 'bearer') + headers = {"Content-Type": "application/json"} + api_key = email_config.get("api_key", "") + auth_method = email_config.get("auth_method", "bearer") - if auth_method == 'bearer': - headers['Authorization'] = f'Bearer {api_key}' - elif auth_method == 'api_key': - headers['X-API-KEY'] = api_key + if auth_method == "bearer": + headers["Authorization"] = f"Bearer {api_key}" + elif auth_method == "api_key": + headers["X-API-KEY"] = api_key # Retry logic - max_retries = email_config.get('max_retries', 3) - retry_delay = email_config.get('retry_delay', 2) - timeout = email_config.get('timeout', 10) + max_retries = email_config.get("max_retries", 3) + retry_delay = email_config.get("retry_delay", 2) + timeout = email_config.get("timeout", 10) for attempt in range(max_retries): try: logger.info(f"Sending email via API (attempt {attempt + 1}/{max_retries}) to {email}") - response = requests.post( - email_config.get('url'), - json=payload, - headers=headers, - timeout=timeout - ) + response = requests.post(email_config.get("url"), json=payload, headers=headers, timeout=timeout) # API runs in background, accept any 2xx response if 200 <= response.status_code < 300: @@ -307,7 +475,7 @@ class NotificationService: # Wait before retry (exponential backoff) if attempt < max_retries - 1: - time.sleep(retry_delay * (2 ** attempt)) + time.sleep(retry_delay * (2**attempt)) return log @@ -329,56 +497,51 @@ class NotificationService: import time # Check if enabled - sms_config = settings.EXTERNAL_NOTIFICATION_API.get('sms', {}) - if not sms_config.get('enabled', False): + sms_config = settings.EXTERNAL_NOTIFICATION_API.get("sms", {}) + if not sms_config.get("enabled", False): logger.warning("SMS API is disabled. Skipping send_sms_via_api") return None # Create notification log log = NotificationLog.objects.create( - channel='sms', + channel="sms", recipient=phone, message=message, content_object=related_object, - provider='api', + provider="api", metadata={ - 'api_url': sms_config.get('url'), - 'auth_method': sms_config.get('auth_method'), - **(metadata or {}) - } + "api_url": sms_config.get("url"), + "auth_method": sms_config.get("auth_method"), + **(metadata or {}), + }, ) # Prepare request payload payload = { - 'to': phone, - 'message': message, + "to": phone, + "message": message, } # Prepare headers - headers = {'Content-Type': 'application/json'} - api_key = sms_config.get('api_key', '') - auth_method = sms_config.get('auth_method', 'bearer') + headers = {"Content-Type": "application/json"} + api_key = sms_config.get("api_key", "") + auth_method = sms_config.get("auth_method", "bearer") - if auth_method == 'bearer': - headers['Authorization'] = f'Bearer {api_key}' - elif auth_method == 'api_key': - headers['X-API-KEY'] = api_key + if auth_method == "bearer": + headers["Authorization"] = f"Bearer {api_key}" + elif auth_method == "api_key": + headers["X-API-KEY"] = api_key # Retry logic - max_retries = sms_config.get('max_retries', 3) - retry_delay = sms_config.get('retry_delay', 2) - timeout = sms_config.get('timeout', 10) + max_retries = sms_config.get("max_retries", 3) + retry_delay = sms_config.get("retry_delay", 2) + timeout = sms_config.get("timeout", 10) for attempt in range(max_retries): try: logger.info(f"Sending SMS via API (attempt {attempt + 1}/{max_retries}) to {phone}") - response = requests.post( - sms_config.get('url'), - json=payload, - headers=headers, - timeout=timeout - ) + response = requests.post(sms_config.get("url"), json=payload, headers=headers, timeout=timeout) # API runs in background, accept any 2xx response if 200 <= response.status_code < 300: @@ -408,12 +571,12 @@ class NotificationService: # Wait before retry (exponential backoff) if attempt < max_retries - 1: - time.sleep(retry_delay * (2 ** attempt)) + time.sleep(retry_delay * (2**attempt)) return log @staticmethod - def send_notification(recipient, title, message, notification_type='general', related_object=None, metadata=None): + def send_notification(recipient, title, message, notification_type="general", related_object=None, metadata=None): """ Send generic notification to a user. @@ -432,8 +595,8 @@ class NotificationService: NotificationLog instance """ # Determine the recipient's contact information - recipient_email = recipient.email if hasattr(recipient, 'email') else None - recipient_phone = recipient.phone if hasattr(recipient, 'phone') else None + recipient_email = recipient.email if hasattr(recipient, "email") else None + recipient_phone = recipient.phone if hasattr(recipient, "phone") else None # Try email first (most reliable) if recipient_email: @@ -443,10 +606,7 @@ class NotificationService: subject=title, message=message, related_object=related_object, - metadata={ - 'notification_type': notification_type, - **(metadata or {}) - } + metadata={"notification_type": notification_type, **(metadata or {})}, ) except Exception as e: logger.warning(f"Failed to send email notification to {recipient_email}: {str(e)}") @@ -460,10 +620,7 @@ class NotificationService: phone=recipient_phone, message=sms_message, related_object=related_object, - metadata={ - 'notification_type': notification_type, - **(metadata or {}) - } + metadata={"notification_type": notification_type, **(metadata or {})}, ) except Exception as e: logger.warning(f"Failed to send SMS notification to {recipient_phone}: {str(e)}") @@ -477,20 +634,17 @@ class NotificationService: # Create a log entry even if we couldn't send return NotificationLog.objects.create( - channel='console', + channel="console", recipient=str(recipient), subject=title, message=message, content_object=related_object, - provider='console', - metadata={ - 'notification_type': notification_type, - **(metadata or {}) - } + provider="console", + metadata={"notification_type": notification_type, **(metadata or {})}, ) @staticmethod - def send_survey_invitation(survey_instance, language='en'): + def send_survey_invitation(survey_instance, language="en"): """ Send survey invitation to patient. @@ -505,9 +659,9 @@ class NotificationService: survey_url = survey_instance.get_survey_url() # Determine recipient based on delivery channel - if survey_instance.delivery_channel == 'sms': + if survey_instance.delivery_channel == "sms": recipient = survey_instance.recipient_phone or patient.phone - if language == 'ar': + if language == "ar": message = f"عزيزي {patient.get_full_name()},\n\nنرجو منك إكمال استبيان تجربتك:\n{survey_url}" else: message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}" @@ -516,12 +670,12 @@ class NotificationService: phone=recipient, message=message, related_object=survey_instance, - metadata={'survey_id': str(survey_instance.id), 'language': language} + metadata={"survey_id": str(survey_instance.id), "language": language}, ) - elif survey_instance.delivery_channel == 'whatsapp': + elif survey_instance.delivery_channel == "whatsapp": recipient = survey_instance.recipient_phone or patient.phone - if language == 'ar': + if language == "ar": message = f"عزيزي {patient.get_full_name()},\n\nنرجو منك إكمال استبيان تجربتك:\n{survey_url}" else: message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}" @@ -530,16 +684,21 @@ class NotificationService: phone=recipient, message=message, related_object=survey_instance, - metadata={'survey_id': str(survey_instance.id), 'language': language} + metadata={"survey_id": str(survey_instance.id), "language": language}, ) else: # email recipient = survey_instance.recipient_email or patient.email - if language == 'ar': - subject = f"استبيان تجربتك - {survey_instance.survey_template.name_ar or survey_instance.survey_template.name}" + survey_label = ( + survey_instance.survey_template.name_ar or survey_instance.survey_template.name + if survey_instance.survey_template + else survey_instance.metadata.get("patient_type", "Patient Experience Survey") + ) + if language == "ar": + subject = f"استبيان تجربتك - {survey_label}" message = f"عزيزي {patient.get_full_name()},\n\nنرجو منك إكمال استبيان تجربتك:\n{survey_url}" else: - subject = f"Your Experience Survey - {survey_instance.survey_template.name}" + subject = f"Your Experience Survey - {survey_label}" message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}" return NotificationService.send_email( @@ -547,7 +706,7 @@ class NotificationService: subject=subject, message=message, related_object=survey_instance, - metadata={'survey_id': str(survey_instance.id), 'language': language} + metadata={"survey_id": str(survey_instance.id), "language": language}, ) @@ -570,3 +729,55 @@ def send_email(email, subject, message, **kwargs): def send_notification(recipient, title, message, **kwargs): """Send generic notification to a user""" return NotificationService.send_notification(recipient, title, message, **kwargs) + + +def create_in_app_notification( + user, + title, + message, + notification_type="system", + title_ar="", + message_ar="", + related_object=None, + action_url="", + email_log=None, +): + """ + Create an in-app notification for a user. + + This is automatically called when sending emails to create + corresponding in-app notifications. + + Args: + user: User instance to notify + title: Notification title (EN) + message: Notification message (EN) + notification_type: Type of notification (e.g., 'complaint_assigned') + title_ar: Arabic title (optional) + message_ar: Arabic message (optional) + related_object: Related model instance (optional) + action_url: URL to navigate when clicked (optional) + email_log: Associated NotificationLog instance (optional) + + Returns: + UserNotification instance + """ + from .models import UserNotification + + notification = UserNotification.objects.create( + user=user, + title=title, + title_ar=title_ar or title, + message=message, + message_ar=message_ar or message, + notification_type=notification_type, + action_url=action_url, + email_log=email_log, + ) + + # Set related object if provided + if related_object: + notification.content_object = related_object + notification.save(update_fields=["content_type", "object_id"]) + + return notification diff --git a/apps/notifications/urls.py b/apps/notifications/urls.py index 9c54bea..56c7b41 100644 --- a/apps/notifications/urls.py +++ b/apps/notifications/urls.py @@ -2,73 +2,36 @@ from django.urls import path from . import views -app_name = 'notifications' +app_name = "notifications" urlpatterns = [ # Notification Settings - path( - 'settings/', - views.notification_settings_view, - name='settings' - ), - path( - 'settings//', - views.notification_settings_view, - name='settings_with_hospital' - ), - + path("settings/", views.notification_settings_view, name="settings"), + path("settings//", views.notification_settings_view, name="settings_with_hospital"), # Settings Update + path("settings/update/", views.notification_settings_update, name="settings_update"), path( - 'settings/update/', - views.notification_settings_update, - name='settings_update' + "settings//update/", views.notification_settings_update, name="settings_update_with_hospital" ), - path( - 'settings//update/', - views.notification_settings_update, - name='settings_update_with_hospital' - ), - # Quiet Hours - path( - 'settings/quiet-hours/', - views.update_quiet_hours, - name='update_quiet_hours' - ), - path( - 'settings//quiet-hours/', - views.update_quiet_hours, - name='update_quiet_hours_with_hospital' - ), - + path("settings/quiet-hours/", views.update_quiet_hours, name="update_quiet_hours"), + path("settings//quiet-hours/", views.update_quiet_hours, name="update_quiet_hours_with_hospital"), # Test Notification - path( - 'settings/test/', - views.test_notification, - name='test_notification' - ), - path( - 'settings//test/', - views.test_notification, - name='test_notification_with_hospital' - ), - + path("settings/test/", views.test_notification, name="test_notification"), + path("settings//test/", views.test_notification, name="test_notification_with_hospital"), # API - path( - 'api/settings/', - views.notification_settings_api, - name='settings_api' - ), - path( - 'api/settings//', - views.notification_settings_api, - name='settings_api_with_hospital' - ), - + path("api/settings/", views.notification_settings_api, name="settings_api"), + path("api/settings//", views.notification_settings_api, name="settings_api_with_hospital"), # Direct SMS Send (Admin only) - path( - 'send-sms/', - views.send_sms_direct, - name='send_sms_direct' - ), + path("send-sms/", views.send_sms_direct, name="send_sms_direct"), + # Notification Inbox + path("inbox/", views.notification_inbox, name="inbox"), + # Notification API Endpoints + path("api/list/", views.notification_list_api, name="notification_list_api"), + path("api/mark-read//", views.mark_notification_read, name="mark_notification_read"), + path("api/mark-all-read/", views.mark_all_notifications_read, name="mark_all_notifications_read"), + path("api/dismiss//", views.dismiss_notification, name="dismiss_notification"), + path("api/dismiss-all/", views.dismiss_all_notifications, name="dismiss_all_notifications"), + path("api/unread-count/", views.unread_notification_count, name="unread_notification_count"), + path("api/latest/", views.latest_notifications, name="latest_notifications"), ] diff --git a/apps/notifications/views.py b/apps/notifications/views.py index 335f9a5..d417abb 100644 --- a/apps/notifications/views.py +++ b/apps/notifications/views.py @@ -3,9 +3,11 @@ Notification Settings Views Provides admin interface for configuring notification preferences. """ + from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied +from django.core.paginator import Paginator from django.shortcuts import render, redirect, get_object_or_404 from django.http import JsonResponse from django.utils.translation import gettext_lazy as _ @@ -19,9 +21,9 @@ def can_manage_notifications(user): """Check if user can manage notification settings""" if user.is_superuser: return True - if hasattr(user, 'is_px_admin') and user.is_px_admin(): + if hasattr(user, "is_px_admin") and user.is_px_admin(): return True - if hasattr(user, 'is_hospital_admin') and user.is_hospital_admin(): + if hasattr(user, "is_hospital_admin") and user.is_hospital_admin(): return True return False @@ -30,238 +32,240 @@ def can_manage_notifications(user): def notification_settings_view(request, hospital_id=None): """ Notification settings configuration page. - + Allows hospital admins to toggle notification preferences for different events and channels. """ # Check permission if not can_manage_notifications(request.user): raise PermissionDenied("You do not have permission to manage notification settings.") - + # Get hospital - if superuser, can view any; otherwise only their hospital if request.user.is_superuser and hospital_id: hospital = get_object_or_404(Hospital, id=hospital_id) else: hospital = request.user.hospital hospital_id = hospital.id - + # Get or create settings settings = HospitalNotificationSettings.get_for_hospital(hospital_id) - + # Group settings by category for display notification_categories = [ { - 'key': 'complaint', - 'name': 'Complaint Notifications', - 'icon': 'bi-exclamation-triangle', - 'description': 'Notifications related to complaint lifecycle', - 'events': [ + "key": "complaint", + "name": "Complaint Notifications", + "icon": "bi-exclamation-triangle", + "description": "Notifications related to complaint lifecycle", + "events": [ { - 'key': 'complaint_acknowledgment', - 'name': 'Complaint Acknowledgment', - 'description': 'Sent to patient when complaint is acknowledged', - 'icon': 'bi-check-circle', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "complaint_acknowledgment", + "name": "Complaint Acknowledgment", + "description": "Sent to patient when complaint is acknowledged", + "icon": "bi-check-circle", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'complaint_assigned', - 'name': 'Complaint Assigned', - 'description': 'Sent to staff when complaint is assigned to them', - 'icon': 'bi-person-check', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "complaint_assigned", + "name": "Complaint Assigned", + "description": "Sent to staff when complaint is assigned to them", + "icon": "bi-person-check", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'complaint_status_changed', - 'name': 'Complaint Status Changed', - 'description': 'Sent when complaint status is updated', - 'icon': 'bi-arrow-repeat', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "complaint_status_changed", + "name": "Complaint Status Changed", + "description": "Sent when complaint status is updated", + "icon": "bi-arrow-repeat", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'complaint_resolved', - 'name': 'Complaint Resolved', - 'description': 'Sent to patient when complaint is resolved', - 'icon': 'bi-check2-all', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "complaint_resolved", + "name": "Complaint Resolved", + "description": "Sent to patient when complaint is resolved", + "icon": "bi-check2-all", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'complaint_closed', - 'name': 'Complaint Closed', - 'description': 'Sent to patient when complaint is closed', - 'icon': 'bi-x-circle', - 'channels': ['email', 'sms', 'whatsapp'] - }, - ] - }, - { - 'key': 'explanation', - 'name': 'Explanation Notifications', - 'icon': 'bi-chat-left-text', - 'description': 'Notifications for staff explanation workflow', - 'events': [ - { - 'key': 'explanation_requested', - 'name': 'Explanation Requested', - 'description': 'Sent to staff when explanation is requested', - 'icon': 'bi-question-circle', - 'channels': ['email', 'sms', 'whatsapp'] - }, - { - 'key': 'explanation_reminder', - 'name': 'Explanation Reminder (24h)', - 'description': 'Reminder sent 24h before SLA deadline', - 'icon': 'bi-clock-history', - 'channels': ['email', 'sms', 'whatsapp'] - }, - { - 'key': 'explanation_overdue', - 'name': 'Explanation Overdue/Escalation', - 'description': 'Sent to manager when explanation is overdue', - 'icon': 'bi-exclamation-diamond', - 'channels': ['email', 'sms', 'whatsapp'] - }, - { - 'key': 'explanation_received', - 'name': 'Explanation Received', - 'description': 'Sent to assignee when staff submits explanation', - 'icon': 'bi-inbox', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "complaint_closed", + "name": "Complaint Closed", + "description": "Sent to patient when complaint is closed", + "icon": "bi-x-circle", + "channels": ["email", "sms", "whatsapp"], }, ], - 'extra_settings': [ + }, + { + "key": "explanation", + "name": "Explanation Notifications", + "icon": "bi-chat-left-text", + "description": "Notifications for staff explanation workflow", + "events": [ { - 'key': 'explanation_manager_cc', - 'name': 'Manager CC on Explanation Request', - 'description': 'CC manager when explanation is requested from staff', - 'icon': 'bi-person-badge' + "key": "explanation_requested", + "name": "Explanation Requested", + "description": "Sent to staff when explanation is requested", + "icon": "bi-question-circle", + "channels": ["email", "sms", "whatsapp"], + }, + { + "key": "explanation_reminder", + "name": "Explanation Reminder (24h)", + "description": "Reminder sent 24h before SLA deadline", + "icon": "bi-clock-history", + "channels": ["email", "sms", "whatsapp"], + }, + { + "key": "explanation_overdue", + "name": "Explanation Overdue/Escalation", + "description": "Sent to manager when explanation is overdue", + "icon": "bi-exclamation-diamond", + "channels": ["email", "sms", "whatsapp"], + }, + { + "key": "explanation_received", + "name": "Explanation Received", + "description": "Sent to assignee when staff submits explanation", + "icon": "bi-inbox", + "channels": ["email", "sms", "whatsapp"], + }, + ], + "extra_settings": [ + { + "key": "explanation_manager_cc", + "name": "Manager CC on Explanation Request", + "description": "CC manager when explanation is requested from staff", + "icon": "bi-person-badge", } - ] + ], }, { - 'key': 'survey', - 'name': 'Survey Notifications', - 'icon': 'bi-clipboard-check', - 'description': 'Notifications for patient surveys', - 'events': [ + "key": "survey", + "name": "Survey Notifications", + "icon": "bi-clipboard-check", + "description": "Notifications for patient surveys", + "events": [ { - 'key': 'survey_invitation', - 'name': 'Survey Invitation', - 'description': 'Initial survey invitation sent to patient', - 'icon': 'bi-envelope-open', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "survey_invitation", + "name": "Survey Invitation", + "description": "Initial survey invitation sent to patient", + "icon": "bi-envelope-open", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'survey_reminder', - 'name': 'Survey Reminder', - 'description': 'Reminder for patients to complete survey', - 'icon': 'bi-bell', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "survey_reminder", + "name": "Survey Reminder", + "description": "Reminder for patients to complete survey", + "icon": "bi-bell", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'survey_completed', - 'name': 'Survey Completed', - 'description': 'Sent to admin when survey is completed', - 'icon': 'bi-check-circle-fill', - 'channels': ['email', 'sms'] + "key": "survey_completed", + "name": "Survey Completed", + "description": "Sent to admin when survey is completed", + "icon": "bi-check-circle-fill", + "channels": ["email", "sms"], }, - ] + ], }, { - 'key': 'action', - 'name': 'Action Plan Notifications', - 'icon': 'bi-list-check', - 'description': 'Notifications for action plan assignments', - 'events': [ + "key": "action", + "name": "Action Plan Notifications", + "icon": "bi-list-check", + "description": "Notifications for action plan assignments", + "events": [ { - 'key': 'action_assigned', - 'name': 'Action Assigned', - 'description': 'Sent to staff when action is assigned', - 'icon': 'bi-person-plus', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "action_assigned", + "name": "Action Assigned", + "description": "Sent to staff when action is assigned", + "icon": "bi-person-plus", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'action_due_soon', - 'name': 'Action Due Soon', - 'description': 'Reminder when action is approaching deadline', - 'icon': 'bi-calendar-event', - 'channels': ['email', 'sms'] + "key": "action_due_soon", + "name": "Action Due Soon", + "description": "Reminder when action is approaching deadline", + "icon": "bi-calendar-event", + "channels": ["email", "sms"], }, { - 'key': 'action_overdue', - 'name': 'Action Overdue', - 'description': 'Alert when action is past deadline', - 'icon': 'bi-calendar-x', - 'channels': ['email', 'sms'] + "key": "action_overdue", + "name": "Action Overdue", + "description": "Alert when action is past deadline", + "icon": "bi-calendar-x", + "channels": ["email", "sms"], }, - ] + ], }, { - 'key': 'sla', - 'name': 'SLA Notifications', - 'icon': 'bi-stopwatch', - 'description': 'Notifications for SLA monitoring', - 'events': [ + "key": "sla", + "name": "SLA Notifications", + "icon": "bi-stopwatch", + "description": "Notifications for SLA monitoring", + "events": [ { - 'key': 'sla_reminder', - 'name': 'SLA Reminder', - 'description': 'Reminder before SLA breach', - 'icon': 'bi-clock', - 'channels': ['email', 'sms'] + "key": "sla_reminder", + "name": "SLA Reminder", + "description": "Reminder before SLA breach", + "icon": "bi-clock", + "channels": ["email", "sms"], }, { - 'key': 'sla_breach', - 'name': 'SLA Breach Alert', - 'description': 'Alert when SLA is breached', - 'icon': 'bi-exclamation-octagon', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "sla_breach", + "name": "SLA Breach Alert", + "description": "Alert when SLA is breached", + "icon": "bi-exclamation-octagon", + "channels": ["email", "sms", "whatsapp"], }, - ] + ], }, { - 'key': 'onboarding', - 'name': 'Onboarding Notifications', - 'icon': 'bi-person-plus', - 'description': 'Notifications for user onboarding and acknowledgements', - 'events': [ + "key": "onboarding", + "name": "Onboarding Notifications", + "icon": "bi-person-plus", + "description": "Notifications for user onboarding and acknowledgements", + "events": [ { - 'key': 'onboarding_invitation', - 'name': 'Onboarding Invitation', - 'description': 'Sent to new provisional users to complete registration', - 'icon': 'bi-envelope-plus', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "onboarding_invitation", + "name": "Onboarding Invitation", + "description": "Sent to new provisional users to complete registration", + "icon": "bi-envelope-plus", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'onboarding_reminder', - 'name': 'Onboarding Reminder', - 'description': 'Reminder to complete onboarding before invitation expires', - 'icon': 'bi-bell', - 'channels': ['email', 'sms', 'whatsapp'] + "key": "onboarding_reminder", + "name": "Onboarding Reminder", + "description": "Reminder to complete onboarding before invitation expires", + "icon": "bi-bell", + "channels": ["email", "sms", "whatsapp"], }, { - 'key': 'onboarding_completion', - 'name': 'Onboarding Completion', - 'description': 'Sent to admins when user completes onboarding', - 'icon': 'bi-check-circle-fill', - 'channels': ['email', 'sms'] + "key": "onboarding_completion", + "name": "Onboarding Completion", + "description": "Sent to admins when user completes onboarding", + "icon": "bi-check-circle-fill", + "channels": ["email", "sms"], }, - ] + ], }, ] - + # Get recent change logs - change_logs = NotificationSettingsLog.objects.filter( - hospital=hospital - ).select_related('changed_by').order_by('-created_at')[:10] - + change_logs = ( + NotificationSettingsLog.objects.filter(hospital=hospital) + .select_related("changed_by") + .order_by("-created_at")[:10] + ) + context = { - 'hospital': hospital, - 'settings': settings, - 'categories': notification_categories, - 'change_logs': change_logs, - 'is_superuser': request.user.is_superuser, + "hospital": hospital, + "settings": settings, + "categories": notification_categories, + "change_logs": change_logs, + "is_superuser": request.user.is_superuser, } - - return render(request, 'notifications/settings.html', context) + + return render(request, "notifications/settings.html", context) @login_required @@ -273,73 +277,59 @@ def notification_settings_update(request, hospital_id=None): # Check permission if not can_manage_notifications(request.user): raise PermissionDenied("You do not have permission to manage notification settings.") - + # Get hospital if request.user.is_superuser and hospital_id: hospital = get_object_or_404(Hospital, id=hospital_id) else: hospital = request.user.hospital - + settings = HospitalNotificationSettings.get_for_hospital(hospital.id) - + # Handle master switch - if 'notifications_enabled' in request.POST: + if "notifications_enabled" in request.POST: old_value = settings.notifications_enabled - new_value = request.POST.get('notifications_enabled') == 'on' + new_value = request.POST.get("notifications_enabled") == "on" settings.notifications_enabled = new_value settings.save() - + NotificationSettingsLog.objects.create( hospital=hospital, changed_by=request.user, - field_name='notifications_enabled', + field_name="notifications_enabled", old_value=old_value, - new_value=new_value + new_value=new_value, ) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({ - 'success': True, - 'message': f"Notifications {'enabled' if new_value else 'disabled'}" - }) + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "message": f"Notifications {'enabled' if new_value else 'disabled'}"}) messages.success(request, f"Notifications {'enabled' if new_value else 'disabled'}") - return redirect('notifications:settings_with_hospital', hospital_id=hospital.id) - + return redirect("notifications:settings_with_hospital", hospital_id=hospital.id) + # Handle individual toggle - field_name = request.POST.get('field') - value = request.POST.get('value') == 'true' - + field_name = request.POST.get("field") + value = request.POST.get("value") == "true" + if field_name and hasattr(settings, field_name): old_value = getattr(settings, field_name) setattr(settings, field_name, value) settings.save() - + # Log the change NotificationSettingsLog.objects.create( - hospital=hospital, - changed_by=request.user, - field_name=field_name, - old_value=old_value, - new_value=value + hospital=hospital, changed_by=request.user, field_name=field_name, old_value=old_value, new_value=value ) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({ - 'success': True, - 'field': field_name, - 'value': value - }) - + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "field": field_name, "value": value}) + messages.success(request, "Setting updated successfully") else: - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({ - 'success': False, - 'error': 'Invalid field name' - }, status=400) + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": False, "error": "Invalid field name"}, status=400) messages.error(request, "Invalid setting") - - return redirect('notifications:settings_with_hospital', hospital_id=hospital.id) + + return redirect("notifications:settings_with_hospital", hospital_id=hospital.id) @login_required @@ -351,21 +341,21 @@ def update_quiet_hours(request, hospital_id=None): # Check permission if not can_manage_notifications(request.user): raise PermissionDenied("You do not have permission to manage notification settings.") - + if request.user.is_superuser and hospital_id: hospital = get_object_or_404(Hospital, id=hospital_id) else: hospital = request.user.hospital - + settings = HospitalNotificationSettings.get_for_hospital(hospital.id) - - settings.quiet_hours_enabled = request.POST.get('quiet_hours_enabled') == 'on' - settings.quiet_hours_start = request.POST.get('quiet_hours_start', '22:00') - settings.quiet_hours_end = request.POST.get('quiet_hours_end', '08:00') + + settings.quiet_hours_enabled = request.POST.get("quiet_hours_enabled") == "on" + settings.quiet_hours_start = request.POST.get("quiet_hours_start", "22:00") + settings.quiet_hours_end = request.POST.get("quiet_hours_end", "08:00") settings.save() - + messages.success(request, "Quiet hours settings updated") - return redirect('notifications:settings_with_hospital', hospital_id=hospital.id) + return redirect("notifications:settings_with_hospital", hospital_id=hospital.id) @login_required @@ -376,42 +366,42 @@ def test_notification(request, hospital_id=None): # Check permission if not can_manage_notifications(request.user): raise PermissionDenied("You do not have permission to manage notification settings.") - + if request.user.is_superuser and hospital_id: hospital = get_object_or_404(Hospital, id=hospital_id) else: hospital = request.user.hospital - + settings = HospitalNotificationSettings.get_for_hospital(hospital.id) - channel = request.POST.get('channel', 'email') - + channel = request.POST.get("channel", "email") + from .services import NotificationService - - if channel == 'email' and request.user.email: + + if channel == "email" and request.user.email: NotificationService.send_email( email=request.user.email, - subject='PX360 Test Notification', - message='This is a test notification from PX360.\n\nIf you received this, your email notifications are working correctly.', - metadata={'test': True, 'hospital_id': str(hospital.id)} + subject="PX360 Test Notification", + message="This is a test notification from PX360.\n\nIf you received this, your email notifications are working correctly.", + metadata={"test": True, "hospital_id": str(hospital.id)}, ) messages.success(request, f"Test email sent to {request.user.email}") - - elif channel == 'sms': - phone = request.POST.get('test_phone') + + elif channel == "sms": + phone = request.POST.get("test_phone") if phone: NotificationService.send_sms( phone=phone, - message='PX360 Test: Your SMS notifications are configured correctly.', - metadata={'test': True, 'hospital_id': str(hospital.id)} + message="PX360 Test: Your SMS notifications are configured correctly.", + metadata={"test": True, "hospital_id": str(hospital.id)}, ) messages.success(request, f"Test SMS sent to {phone}") else: messages.error(request, "Please provide a phone number") - + else: messages.error(request, "Invalid channel selected") - - return redirect('notifications:settings_with_hospital', hospital_id=hospital.id) + + return redirect("notifications:settings_with_hospital", hospital_id=hospital.id) @login_required @@ -424,99 +414,287 @@ def notification_settings_api(request, hospital_id=None): hospital = get_object_or_404(Hospital, id=hospital_id) else: hospital = request.user.hospital - + settings = HospitalNotificationSettings.get_for_hospital(hospital.id) - + # Build response with all settings settings_dict = {} for field in settings._meta.fields: - if field.name not in ['id', 'uuid', 'created_at', 'updated_at', 'hospital']: + if field.name not in ["id", "uuid", "created_at", "updated_at", "hospital"]: settings_dict[field.name] = getattr(settings, field.name) - - return JsonResponse({ - 'hospital_id': str(hospital.id), - 'hospital_name': hospital.name, - 'settings': settings_dict - }) + return JsonResponse({"hospital_id": str(hospital.id), "hospital_name": hospital.name, "settings": settings_dict}) @login_required def send_sms_direct(request): """ - Direct SMS sending page for admins. - - Allows PX Admins and Hospital Admins to send SMS messages -directly to any phone number. + Direct SMS sending page for admins. + + Allows PX Admins and Hospital Admins to send SMS messages + directly to any phone number. """ from .services import NotificationService - + # Check permission - only admins can send direct SMS if not can_manage_notifications(request.user): raise PermissionDenied("You do not have permission to send SMS messages.") - - if request.method == 'POST': - phone_number = request.POST.get('phone_number', '').strip() - message = request.POST.get('message', '').strip() - + + if request.method == "POST": + phone_number = request.POST.get("phone_number", "").strip() + message = request.POST.get("message", "").strip() + # Validate inputs errors = [] if not phone_number: errors.append(_("Phone number is required.")) - elif not phone_number.startswith('+'): + elif not phone_number.startswith("+"): errors.append(_("Phone number must include country code (e.g., +966501234567).")) - + if not message: errors.append(_("Message is required.")) elif len(message) > 1600: errors.append(_("Message is too long. Maximum 1600 characters.")) - + if errors: for error in errors: messages.error(request, error) - return render(request, 'notifications/send_sms_direct.html', { - 'phone_number': phone_number, - 'message': message, - }) - + return render( + request, + "notifications/send_sms_direct.html", + { + "phone_number": phone_number, + "message": message, + }, + ) + try: # Clean phone number - phone_number = phone_number.replace(' ', '').replace('-', '').replace('(', '').replace(')', '') - + phone_number = phone_number.replace(" ", "").replace("-", "").replace("(", "").replace(")", "") + # Send SMS notification_log = NotificationService.send_sms( phone=phone_number, message=message, metadata={ - 'sent_by': str(request.user.id), - 'sent_by_name': request.user.get_full_name(), - 'source': 'direct_sms_send' - } + "sent_by": str(request.user.id), + "sent_by_name": request.user.get_full_name(), + "source": "direct_sms_send", + }, ) - + # Log the action from apps.core.services import AuditService + AuditService.log_event( - event_type='sms_sent_direct', + event_type="sms_sent_direct", description=f"Direct SMS sent to {phone_number} by {request.user.get_full_name()}", user=request.user, metadata={ - 'phone_number': phone_number, - 'message_length': len(message), - 'notification_log_id': str(notification_log.id) if notification_log else None - } + "phone_number": phone_number, + "message_length": len(message), + "notification_log_id": str(notification_log.id) if notification_log else None, + }, ) - - messages.success( - request, - _(f"SMS sent successfully to {phone_number}.") - ) - return redirect('notifications:send_sms_direct') - + + messages.success(request, _(f"SMS sent successfully to {phone_number}.")) + return redirect("notifications:send_sms_direct") + except Exception as e: import logging + logger = logging.getLogger(__name__) logger.error(f"Error sending direct SMS: {str(e)}", exc_info=True) messages.error(request, f"Error sending SMS: {str(e)}") - - return render(request, 'notifications/send_sms_direct.html') + + return render(request, "notifications/send_sms_direct.html") + + +# ============================================================================ +# NOTIFICATION INBOX VIEWS +# ============================================================================ + + +@login_required +def notification_inbox(request): + """ + Notification inbox page. + Displays all non-dismissed notifications for the current user. + """ + from .models import UserNotification + + filter_type = request.GET.get("filter", "all") + + # Base queryset - exclude dismissed + notifications = UserNotification.objects.filter(user=request.user, is_dismissed=False) + + # Apply filters + if filter_type == "unread": + notifications = notifications.filter(is_read=False) + elif filter_type == "read": + notifications = notifications.filter(is_read=True) + + # Pagination + paginator = Paginator(notifications, 20) + page = request.GET.get("page", 1) + notifications_page = paginator.get_page(page) + + context = { + "notifications": notifications_page, + "filter": filter_type, + "unread_count": UserNotification.objects.filter(user=request.user, is_read=False, is_dismissed=False).count(), + } + + return render(request, "notifications/inbox.html", context) + + +@login_required +def notification_list_api(request): + """ + API endpoint to get notifications list (JSON). + """ + from .models import UserNotification + from django.http import JsonResponse + + filter_type = request.GET.get("filter", "all") + page = int(request.GET.get("page", 1)) + + notifications = UserNotification.objects.filter(user=request.user, is_dismissed=False) + + if filter_type == "unread": + notifications = notifications.filter(is_read=False) + elif filter_type == "read": + notifications = notifications.filter(is_read=True) + + paginator = Paginator(notifications, 20) + notifications_page = paginator.get_page(page) + + data = { + "notifications": [ + { + "id": str(n.id), + "title": n.get_title(), + "message": n.get_message(), + "type": n.notification_type, + "is_read": n.is_read, + "created_at": n.created_at.isoformat(), + "action_url": n.action_url, + } + for n in notifications_page + ], + "has_next": notifications_page.has_next(), + "has_previous": notifications_page.has_previous(), + "page": page, + "total_pages": paginator.num_pages, + } + + return JsonResponse(data) + + +@login_required +@require_POST +def mark_notification_read(request, notification_id): + """ + Mark a single notification as read. + """ + from .models import UserNotification + from django.http import JsonResponse + + try: + notification = UserNotification.objects.get(id=notification_id, user=request.user) + notification.mark_as_read() + return JsonResponse({"success": True}) + except UserNotification.DoesNotExist: + return JsonResponse({"success": False, "error": "Notification not found"}, status=404) + + +@login_required +@require_POST +def mark_all_notifications_read(request): + """ + Mark all notifications as read. + """ + from .models import UserNotification + from django.http import JsonResponse + from django.utils import timezone + + UserNotification.objects.filter(user=request.user, is_read=False, is_dismissed=False).update( + is_read=True, read_at=timezone.now() + ) + + return JsonResponse({"success": True}) + + +@login_required +@require_POST +def dismiss_notification(request, notification_id): + """ + Dismiss a notification. + """ + from .models import UserNotification + from django.http import JsonResponse + + try: + notification = UserNotification.objects.get(id=notification_id, user=request.user) + notification.mark_as_dismissed() + return JsonResponse({"success": True}) + except UserNotification.DoesNotExist: + return JsonResponse({"success": False, "error": "Notification not found"}, status=404) + + +@login_required +@require_POST +def dismiss_all_notifications(request): + """ + Dismiss all notifications. + """ + from .models import UserNotification + from django.http import JsonResponse + from django.utils import timezone + + UserNotification.objects.filter(user=request.user, is_dismissed=False).update( + is_dismissed=True, dismissed_at=timezone.now() + ) + + return JsonResponse({"success": True}) + + +@login_required +def unread_notification_count(request): + """ + Get unread notification count. + """ + from .models import UserNotification + from django.http import JsonResponse + + count = UserNotification.objects.filter(user=request.user, is_read=False, is_dismissed=False).count() + + return JsonResponse({"count": count}) + + +@login_required +def latest_notifications(request): + """ + Get latest 5 unread notifications for dropdown. + """ + from .models import UserNotification + from django.http import JsonResponse + + notifications = UserNotification.objects.filter(user=request.user, is_read=False, is_dismissed=False)[:5] + + data = { + "notifications": [ + { + "id": str(n.id), + "title": n.get_title(), + "message": n.get_message()[:100] + "..." if len(n.get_message()) > 100 else n.get_message(), + "type": n.notification_type, + "created_at": n.created_at.isoformat(), + "action_url": n.action_url, + } + for n in notifications + ], + "has_more": UserNotification.objects.filter(user=request.user, is_read=False, is_dismissed=False).count() > 5, + } + + return JsonResponse(data) diff --git a/apps/observations/admin.py b/apps/observations/admin.py index 131be88..f8f8b53 100644 --- a/apps/observations/admin.py +++ b/apps/observations/admin.py @@ -1,8 +1,9 @@ """ Observations admin configuration. """ + from django.contrib import admin -from django.utils.html import format_html +from django.utils.html import format_html, mark_safe from .models import ( Observation, @@ -16,49 +17,50 @@ from .models import ( @admin.register(ObservationCategory) class ObservationCategoryAdmin(admin.ModelAdmin): """Admin for ObservationCategory model.""" - list_display = ['name_en', 'name_ar', 'is_active', 'sort_order', 'observation_count', 'created_at'] - list_filter = ['is_active', 'created_at'] - search_fields = ['name_en', 'name_ar', 'description'] - ordering = ['sort_order', 'name_en'] - + + list_display = ["name_en", "name_ar", "is_active", "sort_order", "observation_count", "created_at"] + list_filter = ["is_active", "created_at"] + search_fields = ["name_en", "name_ar", "description"] + ordering = ["sort_order", "name_en"] + fieldsets = ( - (None, { - 'fields': ('name_en', 'name_ar', 'description') - }), - ('Settings', { - 'fields': ('icon', 'sort_order', 'is_active') - }), + (None, {"fields": ("name_en", "name_ar", "description")}), + ("Settings", {"fields": ("icon", "sort_order", "is_active")}), ) - + def observation_count(self, obj): return obj.observations.count() - observation_count.short_description = 'Observations' + + observation_count.short_description = "Observations" class ObservationAttachmentInline(admin.TabularInline): """Inline admin for ObservationAttachment.""" + model = ObservationAttachment extra = 0 - readonly_fields = ['filename', 'file_type', 'file_size', 'created_at'] - fields = ['file', 'filename', 'file_type', 'file_size', 'description', 'created_at'] + readonly_fields = ["filename", "file_type", "file_size", "created_at"] + fields = ["file", "filename", "file_type", "file_size", "description", "created_at"] class ObservationNoteInline(admin.TabularInline): """Inline admin for ObservationNote.""" + model = ObservationNote extra = 0 - readonly_fields = ['created_by', 'created_at'] - fields = ['note', 'is_internal', 'created_by', 'created_at'] + readonly_fields = ["created_by", "created_at"] + fields = ["note", "is_internal", "created_by", "created_at"] class ObservationStatusLogInline(admin.TabularInline): """Inline admin for ObservationStatusLog.""" + model = ObservationStatusLog extra = 0 - readonly_fields = ['from_status', 'to_status', 'changed_by', 'comment', 'created_at'] - fields = ['from_status', 'to_status', 'changed_by', 'comment', 'created_at'] + readonly_fields = ["from_status", "to_status", "changed_by", "comment", "created_at"] + fields = ["from_status", "to_status", "changed_by", "comment", "created_at"] can_delete = False - + def has_add_permission(self, request, obj=None): return False @@ -66,123 +68,119 @@ class ObservationStatusLogInline(admin.TabularInline): @admin.register(Observation) class ObservationAdmin(admin.ModelAdmin): """Admin for Observation model.""" + list_display = [ - 'tracking_code', 'title_display', 'category', 'severity_badge', - 'status_badge', 'reporter_display', 'assigned_department', - 'assigned_to', 'created_at' - ] - list_filter = [ - 'status', 'severity', 'category', 'assigned_department', - 'created_at', 'triaged_at', 'resolved_at' - ] - search_fields = [ - 'tracking_code', 'title', 'description', - 'reporter_name', 'reporter_staff_id', 'location_text' + "tracking_code", + "title_display", + "category", + "severity_badge", + "status_badge", + "reporter_display", + "assigned_department", + "assigned_to", + "created_at", ] + list_filter = ["status", "severity", "category", "assigned_department", "created_at", "triaged_at", "resolved_at"] + search_fields = ["tracking_code", "title", "description", "reporter_name", "reporter_staff_id", "location_text"] readonly_fields = [ - 'tracking_code', 'created_at', 'updated_at', - 'triaged_at', 'resolved_at', 'closed_at', - 'client_ip', 'user_agent' + "tracking_code", + "created_at", + "updated_at", + "triaged_at", + "resolved_at", + "closed_at", + "client_ip", + "user_agent", ] - ordering = ['-created_at'] - date_hierarchy = 'created_at' - + ordering = ["-created_at"] + date_hierarchy = "created_at" + fieldsets = ( - ('Tracking', { - 'fields': ('tracking_code', 'status') - }), - ('Content', { - 'fields': ('category', 'title', 'description', 'severity') - }), - ('Location & Time', { - 'fields': ('location_text', 'incident_datetime') - }), - ('Reporter Information', { - 'fields': ('reporter_staff_id', 'reporter_name', 'reporter_phone', 'reporter_email'), - 'classes': ('collapse',) - }), - ('Assignment', { - 'fields': ('assigned_department', 'assigned_to') - }), - ('Triage', { - 'fields': ('triaged_by', 'triaged_at'), - 'classes': ('collapse',) - }), - ('Resolution', { - 'fields': ('resolved_by', 'resolved_at', 'resolution_notes'), - 'classes': ('collapse',) - }), - ('Closure', { - 'fields': ('closed_by', 'closed_at'), - 'classes': ('collapse',) - }), - ('Action Center', { - 'fields': ('action_id',), - 'classes': ('collapse',) - }), - ('Metadata', { - 'fields': ('client_ip', 'user_agent', 'metadata', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ("Tracking", {"fields": ("tracking_code", "status")}), + ("Content", {"fields": ("category", "title", "description", "severity")}), + ("Location & Time", {"fields": ("location_text", "incident_datetime")}), + ( + "Reporter Information", + { + "fields": ("reporter_staff_id", "reporter_name", "reporter_phone", "reporter_email"), + "classes": ("collapse",), + }, + ), + ("Assignment", {"fields": ("assigned_department", "assigned_to")}), + ("Triage", {"fields": ("triaged_by", "triaged_at"), "classes": ("collapse",)}), + ("Resolution", {"fields": ("resolved_by", "resolved_at", "resolution_notes"), "classes": ("collapse",)}), + ("Closure", {"fields": ("closed_by", "closed_at"), "classes": ("collapse",)}), + ("Action Center", {"fields": ("action_id",), "classes": ("collapse",)}), + ( + "Metadata", + {"fields": ("client_ip", "user_agent", "metadata", "created_at", "updated_at"), "classes": ("collapse",)}, + ), ) - + inlines = [ObservationAttachmentInline, ObservationNoteInline, ObservationStatusLogInline] - + def title_display(self, obj): if obj.title: - return obj.title[:50] + '...' if len(obj.title) > 50 else obj.title - return obj.description[:50] + '...' if len(obj.description) > 50 else obj.description - title_display.short_description = 'Title/Description' - + return obj.title[:50] + "..." if len(obj.title) > 50 else obj.title + return obj.description[:50] + "..." if len(obj.description) > 50 else obj.description + + title_display.short_description = "Title/Description" + def severity_badge(self, obj): colors = { - 'low': '#28a745', - 'medium': '#ffc107', - 'high': '#dc3545', - 'critical': '#343a40', + "low": "#28a745", + "medium": "#ffc107", + "high": "#dc3545", + "critical": "#343a40", } - color = colors.get(obj.severity, '#6c757d') + color = colors.get(obj.severity, "#6c757d") return format_html( '{}', - color, obj.get_severity_display() + color, + obj.get_severity_display(), ) - severity_badge.short_description = 'Severity' - + + severity_badge.short_description = "Severity" + def status_badge(self, obj): colors = { - 'new': '#007bff', - 'triaged': '#17a2b8', - 'assigned': '#17a2b8', - 'in_progress': '#ffc107', - 'resolved': '#28a745', - 'closed': '#6c757d', - 'rejected': '#dc3545', - 'duplicate': '#6c757d', + "new": "#007bff", + "triaged": "#17a2b8", + "assigned": "#17a2b8", + "in_progress": "#ffc107", + "resolved": "#28a745", + "closed": "#6c757d", + "rejected": "#dc3545", + "duplicate": "#6c757d", } - color = colors.get(obj.status, '#6c757d') + color = colors.get(obj.status, "#6c757d") return format_html( '{}', - color, obj.get_status_display() + color, + obj.get_status_display(), ) - status_badge.short_description = 'Status' - + + status_badge.short_description = "Status" + def reporter_display(self, obj): if obj.is_anonymous: - return format_html('Anonymous') + return mark_safe('Anonymous') return obj.reporter_display - reporter_display.short_description = 'Reporter' + + reporter_display.short_description = "Reporter" @admin.register(ObservationAttachment) class ObservationAttachmentAdmin(admin.ModelAdmin): """Admin for ObservationAttachment model.""" - list_display = ['observation', 'filename', 'file_type', 'file_size_display', 'created_at'] - list_filter = ['file_type', 'created_at'] - search_fields = ['observation__tracking_code', 'filename', 'description'] - readonly_fields = ['file_size', 'created_at'] - + + list_display = ["observation", "filename", "file_type", "file_size_display", "created_at"] + list_filter = ["file_type", "created_at"] + search_fields = ["observation__tracking_code", "filename", "description"] + readonly_fields = ["file_size", "created_at"] + def file_size_display(self, obj): if obj.file_size < 1024: return f"{obj.file_size} B" @@ -190,32 +188,36 @@ class ObservationAttachmentAdmin(admin.ModelAdmin): return f"{obj.file_size / 1024:.1f} KB" else: return f"{obj.file_size / (1024 * 1024):.1f} MB" - file_size_display.short_description = 'Size' + + file_size_display.short_description = "Size" @admin.register(ObservationNote) class ObservationNoteAdmin(admin.ModelAdmin): """Admin for ObservationNote model.""" - list_display = ['observation', 'note_preview', 'created_by', 'is_internal', 'created_at'] - list_filter = ['is_internal', 'created_at'] - search_fields = ['observation__tracking_code', 'note', 'created_by__email'] - readonly_fields = ['created_at'] - + + list_display = ["observation", "note_preview", "created_by", "is_internal", "created_at"] + list_filter = ["is_internal", "created_at"] + search_fields = ["observation__tracking_code", "note", "created_by__email"] + readonly_fields = ["created_at"] + def note_preview(self, obj): - return obj.note[:100] + '...' if len(obj.note) > 100 else obj.note - note_preview.short_description = 'Note' + return obj.note[:100] + "..." if len(obj.note) > 100 else obj.note + + note_preview.short_description = "Note" @admin.register(ObservationStatusLog) class ObservationStatusLogAdmin(admin.ModelAdmin): """Admin for ObservationStatusLog model.""" - list_display = ['observation', 'from_status', 'to_status', 'changed_by', 'created_at'] - list_filter = ['from_status', 'to_status', 'created_at'] - search_fields = ['observation__tracking_code', 'comment', 'changed_by__email'] - readonly_fields = ['observation', 'from_status', 'to_status', 'changed_by', 'comment', 'created_at'] - + + list_display = ["observation", "from_status", "to_status", "changed_by", "created_at"] + list_filter = ["from_status", "to_status", "created_at"] + search_fields = ["observation__tracking_code", "comment", "changed_by__email"] + readonly_fields = ["observation", "from_status", "to_status", "changed_by", "comment", "created_at"] + def has_add_permission(self, request): return False - + def has_change_permission(self, request, obj=None): return False diff --git a/apps/observations/models.py b/apps/observations/models.py index 1b986ee..2be98c8 100644 --- a/apps/observations/models.py +++ b/apps/observations/models.py @@ -8,6 +8,7 @@ This module implements the observation reporting system that: - Links to departments and action center - Maintains audit trail with notes and status logs """ + import secrets import string @@ -24,28 +25,32 @@ def generate_tracking_code(): """Generate a unique tracking code for observations.""" # Format: OBS-XXXXXX (6 alphanumeric characters) chars = string.ascii_uppercase + string.digits - code = ''.join(secrets.choice(chars) for _ in range(6)) + code = "".join(secrets.choice(chars) for _ in range(6)) return f"OBS-{code}" class ObservationSeverity(models.TextChoices): """Observation severity choices.""" - LOW = 'low', 'Low' - MEDIUM = 'medium', 'Medium' - HIGH = 'high', 'High' - CRITICAL = 'critical', 'Critical' + + LOW = "low", "Low" + MEDIUM = "medium", "Medium" + HIGH = "high", "High" + CRITICAL = "critical", "Critical" class ObservationStatus(models.TextChoices): """Observation status choices.""" - NEW = 'new', 'New' - TRIAGED = 'triaged', 'Triaged' - ASSIGNED = 'assigned', 'Assigned' - IN_PROGRESS = 'in_progress', 'In Progress' - RESOLVED = 'resolved', 'Resolved' - CLOSED = 'closed', 'Closed' - REJECTED = 'rejected', 'Rejected' - DUPLICATE = 'duplicate', 'Duplicate' + + NEW = "new", "New" + TRIAGED = "triaged", "Triaged" + ASSIGNED = "assigned", "Assigned" + IN_PROGRESS = "in_progress", "In Progress" + RESOLVED = "resolved", "Resolved" + CLOSED = "closed", "Closed" + REJECTED = "rejected", "Rejected" + DUPLICATE = "duplicate", "Duplicate" + CONTACTED = "contacted", "Contacted" + CONTACTED_NO_RESPONSE = "contacted_no_response", "Contacted, No Response" class ObservationCategory(UUIDModel, TimeStampedModel): @@ -54,6 +59,7 @@ class ObservationCategory(UUIDModel, TimeStampedModel): Supports bilingual names (English and Arabic). """ + name_en = models.CharField(max_length=200, verbose_name="Name (English)") name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") description = models.TextField(blank=True) @@ -66,9 +72,9 @@ class ObservationCategory(UUIDModel, TimeStampedModel): icon = models.CharField(max_length=50, blank=True, help_text="Bootstrap icon class") class Meta: - ordering = ['sort_order', 'name_en'] - verbose_name = 'Observation Category' - verbose_name_plural = 'Observation Categories' + ordering = ["sort_order", "name_en"] + verbose_name = "Observation Category" + verbose_name_plural = "Observation Categories" def __str__(self): return self.name_en @@ -79,6 +85,84 @@ class ObservationCategory(UUIDModel, TimeStampedModel): return self.name_en +class ObservationSLAConfig(UUIDModel, TimeStampedModel): + """ + SLA configuration for observations per hospital and severity. + + Allows flexible SLA configuration for observation resolution times. + """ + + hospital = models.ForeignKey( + "organizations.Hospital", on_delete=models.CASCADE, related_name="observation_sla_configs" + ) + + severity = models.CharField( + max_length=20, + choices=ObservationSeverity.choices, + null=True, + blank=True, + help_text="Severity level for this SLA (optional = default config)", + ) + + sla_hours = models.IntegerField(help_text="Number of hours until SLA deadline") + + first_reminder_hours_after = models.IntegerField( + default=0, help_text="Send 1st reminder X hours after observation creation (0 = use reminder_hours_before)" + ) + + second_reminder_hours_after = models.IntegerField( + default=0, + help_text="Send 2nd reminder X hours after observation creation (0 = use second_reminder_hours_before)", + ) + + escalation_hours_after = models.IntegerField( + default=0, help_text="Escalate observation X hours after creation if unresolved (0 = use overdue logic)" + ) + + reminder_hours_before = models.IntegerField(default=24, help_text="Send first reminder X hours before deadline") + + second_reminder_enabled = models.BooleanField(default=False, help_text="Enable sending a second reminder") + + second_reminder_hours_before = models.IntegerField( + default=6, help_text="Send second reminder X hours before deadline" + ) + + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["hospital", "severity"] + verbose_name = "Observation SLA Config" + verbose_name_plural = "Observation SLA Configs" + indexes = [ + models.Index(fields=["hospital", "is_active"]), + models.Index(fields=["hospital", "severity", "is_active"]), + ] + + def __str__(self): + sev_display = self.severity if self.severity else "Default" + return f"{self.hospital.name} - {sev_display} - {self.sla_hours}h" + + def get_first_reminder_hours_after(self, observation_created_at=None): + if self.first_reminder_hours_after > 0: + return self.first_reminder_hours_after + else: + return max(0, self.sla_hours - self.reminder_hours_before) + + def get_second_reminder_hours_after(self, observation_created_at=None): + if self.second_reminder_hours_after > 0: + return self.second_reminder_hours_after + elif self.second_reminder_enabled: + return max(0, self.sla_hours - self.second_reminder_hours_before) + else: + return 0 + + def get_escalation_hours_after(self, observation_created_at=None): + if self.escalation_hours_after > 0: + return self.escalation_hours_after + else: + return None + + class Observation(UUIDModel, TimeStampedModel): """ Observation - Staff-reported issue or concern. @@ -90,171 +174,139 @@ class Observation(UUIDModel, TimeStampedModel): - Full lifecycle management with status tracking - Links to departments and action center """ + # Tracking tracking_code = models.CharField( max_length=20, unique=True, db_index=True, default=generate_tracking_code, - help_text="Unique code for tracking this observation" + help_text="Unique code for tracking this observation", ) # Classification category = models.ForeignKey( - ObservationCategory, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='observations' + ObservationCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name="observations" ) # Content - title = models.CharField( - max_length=300, - blank=True, - help_text="Optional short title" - ) - description = models.TextField( - help_text="Detailed description of the observation" - ) + title = models.CharField(max_length=300, blank=True, help_text="Optional short title") + description = models.TextField(help_text="Detailed description of the observation") # Severity severity = models.CharField( - max_length=20, - choices=ObservationSeverity.choices, - default=ObservationSeverity.MEDIUM, - db_index=True + max_length=20, choices=ObservationSeverity.choices, default=ObservationSeverity.MEDIUM, db_index=True ) # Location and timing location_text = models.CharField( - max_length=500, - blank=True, - help_text="Where the issue was observed (building, floor, room, etc.)" - ) - incident_datetime = models.DateTimeField( - default=timezone.now, - help_text="When the issue was observed" + max_length=500, blank=True, help_text="Where the issue was observed (building, floor, room, etc.)" ) + incident_datetime = models.DateTimeField(default=timezone.now, help_text="When the issue was observed") # Optional reporter information (anonymous supported) - reporter_staff_id = models.CharField( - max_length=50, - blank=True, - help_text="Optional staff ID of the reporter" - ) - reporter_name = models.CharField( - max_length=200, - blank=True, - help_text="Optional name of the reporter" - ) - reporter_phone = models.CharField( - max_length=20, - blank=True, - help_text="Optional phone number for follow-up" - ) - reporter_email = models.EmailField( - blank=True, - help_text="Optional email for follow-up" - ) + reporter_staff_id = models.CharField(max_length=50, blank=True, help_text="Optional staff ID of the reporter") + reporter_name = models.CharField(max_length=200, blank=True, help_text="Optional name of the reporter") + reporter_phone = models.CharField(max_length=20, blank=True, help_text="Optional phone number for follow-up") + reporter_email = models.EmailField(blank=True, help_text="Optional email for follow-up") # Status and workflow status = models.CharField( - max_length=20, - choices=ObservationStatus.choices, - default=ObservationStatus.NEW, - db_index=True + max_length=25, choices=ObservationStatus.choices, default=ObservationStatus.NEW, db_index=True ) # Organization (required for tenant isolation) hospital = models.ForeignKey( - 'organizations.Hospital', + "organizations.Hospital", on_delete=models.CASCADE, - related_name='observations', - help_text="Hospital where observation was made" + related_name="observations", + help_text="Hospital where observation was made", ) # Staff member mentioned in observation (optional, for AI-matching like complaints) staff = models.ForeignKey( - 'organizations.Staff', + "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, - related_name='observations', - help_text="Staff member mentioned in observation" + related_name="observations", + help_text="Staff member mentioned in observation", ) # Source tracking source = models.CharField( max_length=50, choices=[ - ('staff_portal', 'Staff Portal'), - ('web_form', 'Web Form'), - ('mobile_app', 'Mobile App'), - ('email', 'Email'), - ('call_center', 'Call Center'), - ('other', 'Other'), + ("staff_portal", "Staff Portal"), + ("web_form", "Web Form"), + ("mobile_app", "Mobile App"), + ("email", "Email"), + ("call_center", "Call Center"), + ("other", "Other"), ], - default='staff_portal', - help_text="How the observation was submitted" + default="staff_portal", + help_text="How the observation was submitted", ) # Internal routing assigned_department = models.ForeignKey( - 'organizations.Department', + "organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, - related_name='assigned_observations', - help_text="Department responsible for handling this observation" + related_name="assigned_observations", + help_text="Department responsible for handling this observation", ) assigned_to = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, - related_name='assigned_observations', - help_text="User assigned to handle this observation" + related_name="assigned_observations", + help_text="User assigned to handle this observation", ) + assigned_at = models.DateTimeField(null=True, blank=True) + + # Activation tracking + activated_at = models.DateTimeField( + null=True, + blank=True, + db_index=True, + help_text="Timestamp when observation was first activated (moved to IN_PROGRESS)", + ) + + # SLA tracking + due_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text="SLA deadline") + is_overdue = models.BooleanField(default=False, db_index=True) + breached_at = models.DateTimeField( + null=True, blank=True, db_index=True, help_text="Timestamp when observation first breached SLA" + ) + reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="First SLA reminder timestamp") + second_reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="Second SLA reminder timestamp") + escalated_at = models.DateTimeField(null=True, blank=True) # Triage information triaged_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='triaged_observations' + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="triaged_observations" ) triaged_at = models.DateTimeField(null=True, blank=True) # Resolution resolved_at = models.DateTimeField(null=True, blank=True) resolved_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='resolved_observations' + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_observations" ) resolution_notes = models.TextField(blank=True) # Closure closed_at = models.DateTimeField(null=True, blank=True) closed_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='closed_observations' + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_observations" ) # Link to Action Center (if converted to action) # Using GenericForeignKey on PXAction side, store action_id here for quick reference - action_id = models.UUIDField( - null=True, - blank=True, - help_text="ID of linked PX Action if converted" - ) + action_id = models.UUIDField(null=True, blank=True, help_text="ID of linked PX Action if converted") # Metadata client_ip = models.GenericIPAddressField(null=True, blank=True) @@ -263,29 +315,20 @@ class Observation(UUIDModel, TimeStampedModel): # Notification tracking submitter_notified_at = models.DateTimeField( - null=True, - blank=True, - help_text="When confirmation message was sent to submitter" + null=True, blank=True, help_text="When confirmation message was sent to submitter" ) responsible_person_notified_at = models.DateTimeField( - null=True, - blank=True, - help_text="When email was sent to responsible person" + null=True, blank=True, help_text="When email was sent to responsible person" ) # Monthly follow-up tracking monthly_follow_up_due_at = models.DateTimeField( - null=True, - blank=True, - db_index=True, - help_text="When monthly follow-up is due" + null=True, blank=True, db_index=True, help_text="When monthly follow-up is due" ) monthly_follow_up_completed_at = models.DateTimeField( - null=True, - blank=True, - help_text="When monthly follow-up was completed" + null=True, blank=True, help_text="When monthly follow-up was completed" ) monthly_follow_up_completed_by = models.ForeignKey( @@ -293,28 +336,25 @@ class Observation(UUIDModel, TimeStampedModel): on_delete=models.SET_NULL, null=True, blank=True, - related_name='completed_observation_followups', - help_text="User who completed the monthly follow-up" + related_name="completed_observation_followups", + help_text="User who completed the monthly follow-up", ) - monthly_follow_up_notes = models.TextField( - blank=True, - help_text="Notes from monthly follow-up" - ) + monthly_follow_up_notes = models.TextField(blank=True, help_text="Notes from monthly follow-up") class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] indexes = [ - models.Index(fields=['hospital', 'status', '-created_at']), - models.Index(fields=['status', '-created_at']), - models.Index(fields=['severity', '-created_at']), - models.Index(fields=['tracking_code']), - models.Index(fields=['assigned_department', 'status']), - models.Index(fields=['assigned_to', 'status']), + models.Index(fields=["hospital", "status", "-created_at"]), + models.Index(fields=["status", "-created_at"]), + models.Index(fields=["severity", "-created_at"]), + models.Index(fields=["tracking_code"]), + models.Index(fields=["assigned_department", "status"]), + models.Index(fields=["assigned_to", "status"]), ] permissions = [ - ('triage_observation', 'Can triage observations'), - ('manage_categories', 'Can manage observation categories'), + ("triage_observation", "Can triage observations"), + ("manage_categories", "Can manage observation categories"), ] def __str__(self): @@ -348,53 +388,91 @@ class Observation(UUIDModel, TimeStampedModel): def get_severity_color(self): """Get Bootstrap color class for severity.""" colors = { - 'low': 'success', - 'medium': 'warning', - 'high': 'danger', - 'critical': 'dark', + "low": "success", + "medium": "warning", + "high": "danger", + "critical": "dark", } - return colors.get(self.severity, 'secondary') + return colors.get(self.severity, "secondary") def get_status_color(self): """Get Bootstrap color class for status.""" colors = { - 'new': 'primary', - 'triaged': 'info', - 'assigned': 'info', - 'in_progress': 'warning', - 'resolved': 'success', - 'closed': 'secondary', - 'rejected': 'danger', - 'duplicate': 'secondary', + "new": "primary", + "triaged": "info", + "assigned": "info", + "in_progress": "warning", + "resolved": "success", + "closed": "secondary", + "rejected": "danger", + "duplicate": "secondary", } - return colors.get(self.status, 'secondary') + return colors.get(self.status, "secondary") + + def get_sla_config(self): + """Get SLA configuration for this observation based on hospital and severity.""" + try: + if self.severity: + config = ObservationSLAConfig.objects.filter( + hospital=self.hospital, + severity=self.severity, + is_active=True, + ).first() + if config: + return config + + config = ObservationSLAConfig.objects.filter( + hospital=self.hospital, + is_active=True, + ).first() + if config: + return config + + except Exception: + pass + + return None + + def check_overdue(self): + """Check if observation is overdue and update status""" + inactive_statuses = ["resolved", "closed", "rejected", "duplicate"] + if self.status in inactive_statuses: + return False + + if self.due_at and timezone.now() > self.due_at: + if not self.is_overdue: + self.is_overdue = True + self.breached_at = timezone.now() + self.save(update_fields=["is_overdue", "breached_at"]) + return True + return False + + @property + def is_active_status(self): + """ + Check if observation is in an active status (can be worked on). + Active statuses: new, triaged, assigned, in_progress + Inactive statuses: resolved, closed, rejected, duplicate + """ + return self.status in ["new", "triaged", "assigned", "in_progress"] class ObservationAttachment(UUIDModel, TimeStampedModel): """ Attachment for an observation (photos, documents, etc.). """ - observation = models.ForeignKey( - Observation, - on_delete=models.CASCADE, - related_name='attachments' - ) - file = models.FileField( - upload_to='observations/%Y/%m/%d/', - help_text="Uploaded file" - ) + observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="attachments") + + file = models.FileField(upload_to="observations/%Y/%m/%d/", help_text="Uploaded file") filename = models.CharField(max_length=500, blank=True) file_type = models.CharField(max_length=100, blank=True) - file_size = models.IntegerField( - default=0, - help_text="File size in bytes" - ) + file_size = models.IntegerField(default=0, help_text="File size in bytes") description = models.CharField(max_length=500, blank=True) class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] def __str__(self): return f"{self.observation.tracking_code} - {self.filename}" @@ -404,11 +482,12 @@ class ObservationAttachment(UUIDModel, TimeStampedModel): if self.file: if not self.filename: self.filename = self.file.name - if not self.file_size and hasattr(self.file, 'size'): + if not self.file_size and hasattr(self.file, "size"): self.file_size = self.file.size if not self.file_type: import mimetypes - self.file_type = mimetypes.guess_type(self.file.name)[0] or '' + + self.file_type = mimetypes.guess_type(self.file.name)[0] or "" super().save(*args, **kwargs) @@ -418,29 +497,20 @@ class ObservationNote(UUIDModel, TimeStampedModel): Used by PX360 staff to add comments and updates. """ - observation = models.ForeignKey( - Observation, - on_delete=models.CASCADE, - related_name='notes' - ) + + observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="notes") note = models.TextField() created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name='observation_notes' + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="observation_notes" ) # Flag for internal-only notes - is_internal = models.BooleanField( - default=True, - help_text="Internal notes are not visible to public" - ) + is_internal = models.BooleanField(default=True, help_text="Internal notes are not visible to public") class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] def __str__(self): return f"Note on {self.observation.tracking_code} by {self.created_by}" @@ -452,39 +522,26 @@ class ObservationStatusLog(UUIDModel, TimeStampedModel): Tracks all status transitions for audit trail. """ - observation = models.ForeignKey( - Observation, - on_delete=models.CASCADE, - related_name='status_logs' - ) - from_status = models.CharField( - max_length=20, - choices=ObservationStatus.choices, - blank=True - ) - to_status = models.CharField( - max_length=20, - choices=ObservationStatus.choices - ) + observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="status_logs") + + from_status = models.CharField(max_length=25, choices=ObservationStatus.choices, blank=True) + to_status = models.CharField(max_length=25, choices=ObservationStatus.choices) changed_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, - related_name='observation_status_changes' + related_name="observation_status_changes", ) - comment = models.TextField( - blank=True, - help_text="Optional comment about the status change" - ) + comment = models.TextField(blank=True, help_text="Optional comment about the status change") class Meta: - ordering = ['-created_at'] - verbose_name = 'Observation Status Log' - verbose_name_plural = 'Observation Status Logs' + ordering = ["-created_at"] + verbose_name = "Observation Status Log" + verbose_name_plural = "Observation Status Logs" def __str__(self): return f"{self.observation.tracking_code}: {self.from_status} → {self.to_status}" diff --git a/apps/observations/services.py b/apps/observations/services.py index b4b3ad9..ec69fa9 100644 --- a/apps/observations/services.py +++ b/apps/observations/services.py @@ -7,6 +7,7 @@ This module provides services for: - Sending notifications - Status management with audit logging """ + import logging from typing import Optional @@ -35,27 +36,27 @@ class ObservationService: """ Service class for observation management. """ - + @staticmethod @transaction.atomic def create_observation( description: str, - severity: str = 'medium', + severity: str = "medium", category=None, - title: str = '', - location_text: str = '', + title: str = "", + location_text: str = "", incident_datetime=None, - reporter_staff_id: str = '', - reporter_name: str = '', - reporter_phone: str = '', - reporter_email: str = '', + reporter_staff_id: str = "", + reporter_name: str = "", + reporter_phone: str = "", + reporter_email: str = "", client_ip: str = None, - user_agent: str = '', + user_agent: str = "", attachments: list = None, ) -> Observation: """ Create a new observation. - + Args: description: Detailed description of the observation severity: Severity level (low, medium, high, critical) @@ -70,7 +71,7 @@ class ObservationService: client_ip: IP address of submitter (optional) user_agent: Browser user agent (optional) attachments: List of uploaded files (optional) - + Returns: Created Observation instance """ @@ -88,15 +89,12 @@ class ObservationService: client_ip=client_ip, user_agent=user_agent, ) - + # Create initial status log ObservationStatusLog.objects.create( - observation=observation, - from_status='', - to_status=ObservationStatus.NEW, - comment='Observation submitted' + observation=observation, from_status="", to_status=ObservationStatus.NEW, comment="Observation submitted" ) - + # Handle attachments if attachments: for file in attachments: @@ -104,51 +102,60 @@ class ObservationService: observation=observation, file=file, ) - + # Send notification to PX360 triage team ObservationService.notify_new_observation(observation) - + logger.info(f"Created observation {observation.tracking_code}") return observation - + @staticmethod @transaction.atomic def change_status( observation: Observation, new_status: str, changed_by: User = None, - comment: str = '', + comment: str = "", ) -> ObservationStatusLog: """ Change observation status with audit logging. - + Args: observation: Observation instance new_status: New status value changed_by: User making the change (optional) comment: Comment about the change (optional) - + Returns: Created ObservationStatusLog instance """ old_status = observation.status - + # Update observation observation.status = new_status - + # Handle status-specific updates if new_status == ObservationStatus.TRIAGED: observation.triaged_at = timezone.now() observation.triaged_by = changed_by + elif new_status == ObservationStatus.IN_PROGRESS: + if not observation.activated_at: + observation.activated_at = timezone.now() + if not observation.due_at: + sla_config = observation.get_sla_config() + if sla_config: + from datetime import timedelta + + observation.due_at = observation.activated_at + timedelta(hours=sla_config.sla_hours) elif new_status == ObservationStatus.RESOLVED: observation.resolved_at = timezone.now() observation.resolved_by = changed_by elif new_status == ObservationStatus.CLOSED: observation.closed_at = timezone.now() observation.closed_by = changed_by - + observation.save() - + # Create status log status_log = ObservationStatusLog.objects.create( observation=observation, @@ -157,20 +164,19 @@ class ObservationService: changed_by=changed_by, comment=comment, ) - + # Send notifications based on status change if new_status == ObservationStatus.ASSIGNED and observation.assigned_to: ObservationService.notify_assignment(observation) elif new_status in [ObservationStatus.RESOLVED, ObservationStatus.CLOSED]: ObservationService.notify_resolution(observation) - + logger.info( - f"Observation {observation.tracking_code} status changed: " - f"{old_status} -> {new_status} by {changed_by}" + f"Observation {observation.tracking_code} status changed: {old_status} -> {new_status} by {changed_by}" ) - + return status_log - + @staticmethod @transaction.atomic def triage_observation( @@ -179,11 +185,11 @@ class ObservationService: assigned_department: Department = None, assigned_to: User = None, new_status: str = None, - note: str = '', + note: str = "", ) -> Observation: """ Triage an observation - assign department/owner and update status. - + Args: observation: Observation instance triaged_by: User performing the triage @@ -191,7 +197,7 @@ class ObservationService: assigned_to: User to assign (optional) new_status: New status (optional, defaults to TRIAGED or ASSIGNED) note: Triage note (optional) - + Returns: Updated Observation instance """ @@ -200,24 +206,24 @@ class ObservationService: observation.assigned_department = assigned_department if assigned_to: observation.assigned_to = assigned_to - + # Determine new status if not new_status: if assigned_to: new_status = ObservationStatus.ASSIGNED else: new_status = ObservationStatus.TRIAGED - + observation.save() - + # Change status ObservationService.change_status( observation=observation, new_status=new_status, changed_by=triaged_by, - comment=note or f"Triaged by {triaged_by.get_full_name()}" + comment=note or f"Triaged by {triaged_by.get_full_name()}", ) - + # Add note if provided if note: ObservationNote.objects.create( @@ -226,9 +232,9 @@ class ObservationService: created_by=triaged_by, is_internal=True, ) - + return observation - + @staticmethod @transaction.atomic def convert_to_action( @@ -236,14 +242,14 @@ class ObservationService: created_by: User, title: str = None, description: str = None, - category: str = 'other', + category: str = "other", priority: str = None, assigned_department: Department = None, assigned_to: User = None, ): """ Convert an observation to a PX Action. - + Args: observation: Observation instance created_by: User creating the action @@ -253,12 +259,12 @@ class ObservationService: priority: Action priority (optional, derived from severity) assigned_department: Department to assign (optional) assigned_to: User to assign (optional) - + Returns: Created PXAction instance """ from apps.px_action_center.models import ActionSource, PXAction - + # Get hospital from department or use first hospital hospital = None if assigned_department: @@ -267,10 +273,10 @@ class ObservationService: hospital = observation.assigned_department.hospital else: hospital = Hospital.objects.first() - + if not hospital: raise ValueError("No hospital found for creating action") - + # Prepare title and description if not title: title = f"Observation {observation.tracking_code}" @@ -278,15 +284,15 @@ class ObservationService: title += f" - {observation.title}" elif observation.category: title += f" - {observation.category.name_en}" - + if not description: description = f""" Observation Details: - Tracking Code: {observation.tracking_code} -- Category: {observation.category.name_en if observation.category else 'N/A'} +- Category: {observation.category.name_en if observation.category else "N/A"} - Severity: {observation.get_severity_display()} -- Location: {observation.location_text or 'N/A'} -- Incident Date: {observation.incident_datetime.strftime('%Y-%m-%d %H:%M')} +- Location: {observation.location_text or "N/A"} +- Incident Date: {observation.incident_datetime.strftime("%Y-%m-%d %H:%M")} - Reporter: {observation.reporter_display} Description: @@ -294,17 +300,17 @@ Description: View observation: /observations/{observation.id}/ """.strip() - + # Map severity to priority if not priority: severity_to_priority = { - 'low': 'low', - 'medium': 'medium', - 'high': 'high', - 'critical': 'critical', + "low": "low", + "medium": "medium", + "high": "high", + "critical": "critical", } - priority = severity_to_priority.get(observation.severity, 'medium') - + priority = severity_to_priority.get(observation.severity, "medium") + # Create PX Action action = PXAction.objects.create( source_type=ActionSource.MANUAL, @@ -319,15 +325,15 @@ View observation: /observations/{observation.id}/ severity=observation.severity, assigned_to=assigned_to or observation.assigned_to, metadata={ - 'observation_tracking_code': observation.tracking_code, - 'observation_id': str(observation.id), - } + "observation_tracking_code": observation.tracking_code, + "observation_id": str(observation.id), + }, ) - + # Update observation with action link observation.action_id = action.id - observation.save(update_fields=['action_id']) - + observation.save(update_fields=["action_id"]) + # Add note to observation ObservationNote.objects.create( observation=observation, @@ -335,13 +341,11 @@ View observation: /observations/{observation.id}/ created_by=created_by, is_internal=True, ) - - logger.info( - f"Observation {observation.tracking_code} converted to action {action.id}" - ) - + + logger.info(f"Observation {observation.tracking_code} converted to action {action.id}") + return action - + @staticmethod def add_note( observation: Observation, @@ -351,13 +355,13 @@ View observation: /observations/{observation.id}/ ) -> ObservationNote: """ Add a note to an observation. - + Args: observation: Observation instance note: Note text created_by: User creating the note is_internal: Whether the note is internal-only - + Returns: Created ObservationNote instance """ @@ -367,21 +371,21 @@ View observation: /observations/{observation.id}/ created_by=created_by, is_internal=is_internal, ) - + @staticmethod def add_attachment( observation: Observation, file, - description: str = '', + description: str = "", ) -> ObservationAttachment: """ Add an attachment to an observation. - + Args: observation: Observation instance file: Uploaded file description: File description (optional) - + Returns: Created ObservationAttachment instance """ @@ -390,38 +394,35 @@ View observation: /observations/{observation.id}/ file=file, description=description, ) - + @staticmethod def notify_new_observation(observation: Observation): """ Send notification for new observation to PX360 triage team. - + Args: observation: Observation instance """ try: # Get PX Admin users to notify - px_admins = User.objects.filter( - is_active=True, - groups__name='PX Admin' - ) - + px_admins = User.objects.filter(is_active=True, groups__name="PX Admin") + subject = f"New Observation: {observation.tracking_code}" message = f""" A new observation has been submitted and requires triage. Tracking Code: {observation.tracking_code} -Category: {observation.category.name_en if observation.category else 'N/A'} +Category: {observation.category.name_en if observation.category else "N/A"} Severity: {observation.get_severity_display()} -Location: {observation.location_text or 'N/A'} +Location: {observation.location_text or "N/A"} Reporter: {observation.reporter_display} Description: -{observation.description[:500]}{'...' if len(observation.description) > 500 else ''} +{observation.description[:500]}{"..." if len(observation.description) > 500 else ""} Please review and triage this observation. """.strip() - + for admin in px_admins: if admin.email: NotificationService.send_email( @@ -430,44 +431,44 @@ Please review and triage this observation. message=message, related_object=observation, metadata={ - 'observation_id': str(observation.id), - 'tracking_code': observation.tracking_code, - 'notification_type': 'new_observation', - } + "observation_id": str(observation.id), + "tracking_code": observation.tracking_code, + "notification_type": "new_observation", + }, ) - + logger.info(f"Sent new observation notification for {observation.tracking_code}") - + except Exception as e: logger.error(f"Failed to send new observation notification: {e}") - + @staticmethod def notify_assignment(observation: Observation): """ Send notification when observation is assigned. - + Args: observation: Observation instance """ try: if not observation.assigned_to: return - + subject = f"Observation Assigned: {observation.tracking_code}" message = f""" An observation has been assigned to you. Tracking Code: {observation.tracking_code} -Category: {observation.category.name_en if observation.category else 'N/A'} +Category: {observation.category.name_en if observation.category else "N/A"} Severity: {observation.get_severity_display()} -Location: {observation.location_text or 'N/A'} +Location: {observation.location_text or "N/A"} Description: -{observation.description[:500]}{'...' if len(observation.description) > 500 else ''} +{observation.description[:500]}{"..." if len(observation.description) > 500 else ""} Please review and take appropriate action. """.strip() - + if observation.assigned_to.email: NotificationService.send_email( email=observation.assigned_to.email, @@ -475,54 +476,53 @@ Please review and take appropriate action. message=message, related_object=observation, metadata={ - 'observation_id': str(observation.id), - 'tracking_code': observation.tracking_code, - 'notification_type': 'observation_assigned', - } + "observation_id": str(observation.id), + "tracking_code": observation.tracking_code, + "notification_type": "observation_assigned", + }, ) - + logger.info( - f"Sent assignment notification for {observation.tracking_code} " - f"to {observation.assigned_to.email}" + f"Sent assignment notification for {observation.tracking_code} to {observation.assigned_to.email}" ) - + except Exception as e: logger.error(f"Failed to send assignment notification: {e}") - + @staticmethod def notify_resolution(observation: Observation): """ Send internal notification when observation is resolved/closed. - + Args: observation: Observation instance """ try: # Notify assigned user and department manager recipients = [] - + if observation.assigned_to and observation.assigned_to.email: recipients.append(observation.assigned_to.email) - + if observation.assigned_department and observation.assigned_department.manager: if observation.assigned_department.manager.email: recipients.append(observation.assigned_department.manager.email) - + if not recipients: return - + subject = f"Observation {observation.get_status_display()}: {observation.tracking_code}" message = f""" An observation has been {observation.get_status_display().lower()}. Tracking Code: {observation.tracking_code} -Category: {observation.category.name_en if observation.category else 'N/A'} +Category: {observation.category.name_en if observation.category else "N/A"} Status: {observation.get_status_display()} Resolution Notes: -{observation.resolution_notes or 'No resolution notes provided.'} +{observation.resolution_notes or "No resolution notes provided."} """.strip() - + for email in set(recipients): NotificationService.send_email( email=email, @@ -530,35 +530,35 @@ Resolution Notes: message=message, related_object=observation, metadata={ - 'observation_id': str(observation.id), - 'tracking_code': observation.tracking_code, - 'notification_type': 'observation_resolved', - } + "observation_id": str(observation.id), + "tracking_code": observation.tracking_code, + "notification_type": "observation_resolved", + }, ) - + logger.info(f"Sent resolution notification for {observation.tracking_code}") - + except Exception as e: logger.error(f"Failed to send resolution notification: {e}") - + @staticmethod def get_statistics(hospital=None, department=None, date_from=None, date_to=None): """ Get observation statistics. - + Args: hospital: Filter by hospital (optional) department: Filter by department (optional) date_from: Start date (optional) date_to: End date (optional) - + Returns: Dictionary with statistics """ from django.db.models import Count, Q - + queryset = Observation.objects.all() - + if hospital: queryset = queryset.filter(assigned_department__hospital=hospital) if department: @@ -567,33 +567,29 @@ Resolution Notes: queryset = queryset.filter(created_at__gte=date_from) if date_to: queryset = queryset.filter(created_at__lte=date_to) - + # Status counts - status_counts = queryset.values('status').annotate(count=Count('id')) - status_dict = {item['status']: item['count'] for item in status_counts} - + status_counts = queryset.values("status").annotate(count=Count("id")) + status_dict = {item["status"]: item["count"] for item in status_counts} + # Severity counts - severity_counts = queryset.values('severity').annotate(count=Count('id')) - severity_dict = {item['severity']: item['count'] for item in severity_counts} - + severity_counts = queryset.values("severity").annotate(count=Count("id")) + severity_dict = {item["severity"]: item["count"] for item in severity_counts} + # Category counts - category_counts = queryset.values( - 'category__name_en' - ).annotate(count=Count('id')).order_by('-count')[:10] - + category_counts = queryset.values("category__name_en").annotate(count=Count("id")).order_by("-count")[:10] + return { - 'total': queryset.count(), - 'new': status_dict.get('new', 0), - 'triaged': status_dict.get('triaged', 0), - 'assigned': status_dict.get('assigned', 0), - 'in_progress': status_dict.get('in_progress', 0), - 'resolved': status_dict.get('resolved', 0), - 'closed': status_dict.get('closed', 0), - 'rejected': status_dict.get('rejected', 0), - 'duplicate': status_dict.get('duplicate', 0), - 'anonymous_count': queryset.filter( - Q(reporter_staff_id='') & Q(reporter_name='') - ).count(), - 'severity': severity_dict, - 'top_categories': list(category_counts), + "total": queryset.count(), + "new": status_dict.get("new", 0), + "triaged": status_dict.get("triaged", 0), + "assigned": status_dict.get("assigned", 0), + "in_progress": status_dict.get("in_progress", 0), + "resolved": status_dict.get("resolved", 0), + "closed": status_dict.get("closed", 0), + "rejected": status_dict.get("rejected", 0), + "duplicate": status_dict.get("duplicate", 0), + "anonymous_count": queryset.filter(Q(reporter_staff_id="") & Q(reporter_name="")).count(), + "severity": severity_dict, + "top_categories": list(category_counts), } diff --git a/apps/observations/tasks.py b/apps/observations/tasks.py new file mode 100644 index 0000000..ea063b7 --- /dev/null +++ b/apps/observations/tasks.py @@ -0,0 +1,130 @@ +""" +Observations Celery tasks - SLA tracking and notifications. + +This module implements tasks for: +- Checking overdue observations +- Sending SLA reminder emails +- Escalation handling +""" + +import logging + +from celery import shared_task +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +@shared_task +def check_overdue_observations(): + """ + Periodic task to check for overdue observations. + + Runs every 15 minutes (configured in config/celery.py). + Updates is_overdue flag and sets breached_at for observations past their SLA deadline. + """ + from apps.observations.models import Observation, ObservationStatus + + active_statuses = [ + ObservationStatus.NEW, + ObservationStatus.TRIAGED, + ObservationStatus.ASSIGNED, + ObservationStatus.IN_PROGRESS, + ] + active_observations = Observation.objects.filter( + status__in=active_statuses, + due_at__isnull=False, + ).select_related("hospital", "assigned_department") + + overdue_count = 0 + + for observation in active_observations: + if observation.check_overdue(): + overdue_count += 1 + logger.warning( + f"Observation {observation.id} is overdue: {observation.tracking_code} (due: {observation.due_at})" + ) + + if overdue_count > 0: + logger.info(f"Found {overdue_count} overdue observations") + + return {"overdue_count": overdue_count} + + +@shared_task +def send_observation_sla_reminders(): + """ + Periodic task to send SLA reminder emails for observations approaching deadline. + + Runs every hour (configured in config/celery.py). + """ + from apps.observations.models import Observation, ObservationStatus, ObservationSLAConfig + from apps.notifications.services import NotificationService + + now = timezone.now() + active_statuses = [ + ObservationStatus.NEW, + ObservationStatus.TRIAGED, + ObservationStatus.ASSIGNED, + ObservationStatus.IN_PROGRESS, + ] + + active_observations = Observation.objects.filter( + status__in=active_statuses, + due_at__isnull=False, + is_overdue=False, + ).select_related("hospital", "assigned_to") + + first_reminder_count = 0 + second_reminder_count = 0 + + for observation in active_observations: + config = observation.get_sla_config() + if not config: + continue + + first_reminder_hours = config.get_first_reminder_hours_after() + second_reminder_hours = config.get_second_reminder_hours_after() + + if first_reminder_hours > 0 and observation.reminder_sent_at is None: + hours_since_creation = (now - observation.created_at).total_seconds() / 3600 + if hours_since_creation >= first_reminder_hours: + if observation.assigned_to and observation.assigned_to.email: + try: + NotificationService.send_email( + email=observation.assigned_to.email, + subject=f"SLA Reminder - Observation {observation.tracking_code}", + message=f"Observation '{observation.title or observation.description[:50]}' is due at {observation.due_at}. Please take action.", + related_object=observation, + ) + observation.reminder_sent_at = now + observation.save(update_fields=["reminder_sent_at"]) + first_reminder_count += 1 + except Exception as e: + logger.error(f"Failed to send observation reminder: {e}") + + if second_reminder_hours > 0 and observation.second_reminder_sent_at is None: + hours_since_creation = (now - observation.created_at).total_seconds() / 3600 + if hours_since_creation >= second_reminder_hours: + if observation.assigned_to and observation.assigned_to.email: + try: + NotificationService.send_email( + email=observation.assigned_to.email, + subject=f"URGENT: SLA Reminder - Observation {observation.tracking_code}", + message=f"Observation '{observation.title or observation.description[:50]}' is due at {observation.due_at}. URGENT action required.", + related_object=observation, + ) + observation.second_reminder_sent_at = now + observation.save(update_fields=["second_reminder_sent_at"]) + second_reminder_count += 1 + except Exception as e: + logger.error(f"Failed to send second observation reminder: {e}") + + logger.info( + f"Sent {first_reminder_count} first reminders and {second_reminder_count} second reminders for observations" + ) + + return { + "first_reminder_count": first_reminder_count, + "second_reminder_count": second_reminder_count, + } diff --git a/apps/organizations/management/commands/import_staff_full.py b/apps/organizations/management/commands/import_staff_full.py index 939fce7..8fc8507 100644 --- a/apps/organizations/management/commands/import_staff_full.py +++ b/apps/organizations/management/commands/import_staff_full.py @@ -15,31 +15,42 @@ Staff ID,Name,Name_ar,Manager,Manager_ar,Civil Identity Number,Location,Location Example: 4,ABDULAZIZ SALEH ALHAMMADI,عبدالعزيز صالح محمد الحمادي,2 - MOHAMMAD SALEH AL HAMMADI,2 - محمد صالح محمد الحمادي,1013086457,Nuzha,النزهة,Senior Management Offices, إدارة مكاتب الإدارة العليا ,COO Office,مكتب الرئيس التنفيذي للعمليات والتشغيل,,,Chief Operating Officer,الرئيس التنفيذي للعمليات والتشغيل,Saudi Arabia,المملكة العربية السعودية """ + import csv +import json +import logging import os import uuid +from typing import Dict, List + from django.core.management.base import BaseCommand, CommandError from django.db import transaction from apps.organizations.models import Hospital, Department, Staff, StaffSection, StaffSubsection +logger = logging.getLogger(__name__) + class Command(BaseCommand): - help = 'Import staff from CSV with auto-creation of departments and sections (bilingual support)' + help = "Import staff from CSV with auto-creation of departments and sections (bilingual support)" def add_arguments(self, parser): - parser.add_argument('csv_file', type=str, help='Path to CSV file') - parser.add_argument('--hospital-code', type=str, required=True, help='Hospital code') - parser.add_argument('--staff-type', type=str, default='admin', choices=['physician', 'nurse', 'admin', 'other']) - parser.add_argument('--update-existing', action='store_true', help='Update existing staff') - parser.add_argument('--dry-run', action='store_true', help='Preview without changes') + parser.add_argument("csv_file", type=str, help="Path to CSV file") + parser.add_argument("--hospital-code", type=str, required=True, help="Hospital code") + parser.add_argument("--staff-type", type=str, default="admin", choices=["physician", "nurse", "admin", "other"]) + parser.add_argument("--update-existing", action="store_true", help="Update existing staff") + parser.add_argument( + "--translate-departments", action="store_true", help="Use AI to translate department names to Arabic" + ) + parser.add_argument("--dry-run", action="store_true", help="Preview without changes") def handle(self, *args, **options): - csv_file = options['csv_file'] - hospital_code = options['hospital_code'] - staff_type = options['staff_type'] - update_existing = options['update_existing'] - dry_run = options['dry_run'] + csv_file = options["csv_file"] + hospital_code = options["hospital_code"] + staff_type = options["staff_type"] + update_existing = options["update_existing"] + translate_departments = options["translate_departments"] + dry_run = options["dry_run"] if not os.path.exists(csv_file): raise CommandError(f"CSV file not found: {csv_file}") @@ -57,10 +68,31 @@ class Command(BaseCommand): staff_data = self.parse_csv(csv_file) self.stdout.write(f"Found {len(staff_data)} records in CSV\n") + # Translate department names if requested + dept_translations: Dict[str, str] = {} + if translate_departments: + self.stdout.write(self.style.WARNING("Translating department names to Arabic via AI...")) + dept_translations = self._translate_department_names(staff_data) + if dept_translations: + self.stdout.write(f" Translated {len(dept_translations)} unique department names\n") + for row in staff_data: + if not row["department_ar"] and row["department"] in dept_translations: + row["department_ar"] = dept_translations[row["department"]] + else: + self.stdout.write(self.style.WARNING(" No translations received from AI\n")) + # Statistics - stats = {'created': 0, 'updated': 0, 'skipped': 0, 'depts_created': 0, 'sections_created': 0, - 'subsections_created': 0, 'managers_linked': 0, 'errors': 0} - + stats = { + "created": 0, + "updated": 0, + "skipped": 0, + "depts_created": 0, + "sections_created": 0, + "subsections_created": 0, + "managers_linked": 0, + "errors": 0, + } + # Caches dept_cache = {} # {(hospital_id, dept_name): Department} section_cache = {} # {(department_id, section_name): StaffSection} @@ -73,66 +105,63 @@ class Command(BaseCommand): try: # Get or create department (top-level only) department = self._get_or_create_department( - hospital, row['department'], row.get('department_ar', ''), - dept_cache, dry_run, stats + hospital, row["department"], row.get("department_ar", ""), dept_cache, dry_run, stats ) - + # Get or create section (under department) section = self._get_or_create_section( - department, row['section'], row.get('section_ar', ''), - section_cache, dry_run, stats + department, row["section"], row.get("section_ar", ""), section_cache, dry_run, stats ) - + # Get or create subsection (under section) subsection = self._get_or_create_subsection( - section, row['subsection'], row.get('subsection_ar', ''), - subsection_cache, dry_run, stats + section, row["subsection"], row.get("subsection_ar", ""), subsection_cache, dry_run, stats ) - + # Check existing - existing = Staff.objects.filter(employee_id=row['staff_id']).first() - + existing = Staff.objects.filter(employee_id=row["staff_id"]).first() + if existing and not update_existing: self.stdout.write(f"[{idx}] ⊘ Skipped (exists): {row['name']}") - stats['skipped'] += 1 - staff_map[row['staff_id']] = existing + stats["skipped"] += 1 + staff_map[row["staff_id"]] = existing continue - + if existing: self._update_staff(existing, row, hospital, department, section, subsection, staff_type) if not dry_run: existing.save() self.stdout.write(f"[{idx}] ✓ Updated: {row['name']}") - stats['updated'] += 1 - staff_map[row['staff_id']] = existing + stats["updated"] += 1 + staff_map[row["staff_id"]] = existing else: staff = self._create_staff(row, hospital, department, section, subsection, staff_type) if not dry_run: staff.save() - staff_map[row['staff_id']] = staff + staff_map[row["staff_id"]] = staff self.stdout.write(f"[{idx}] ✓ Created: {row['name']}") - stats['created'] += 1 - + stats["created"] += 1 + except Exception as e: self.stdout.write(self.style.ERROR(f"[{idx}] ✗ Error: {row.get('name', 'Unknown')} - {e}")) - stats['errors'] += 1 + stats["errors"] += 1 # Pass 2: Link managers self.stdout.write("\nLinking managers...") for row in staff_data: - if not row.get('manager_id'): + if not row.get("manager_id"): continue - staff = staff_map.get(row['staff_id']) - manager = staff_map.get(row['manager_id']) + staff = staff_map.get(row["staff_id"]) + manager = staff_map.get(row["manager_id"]) if staff and manager and staff.report_to != manager: staff.report_to = manager if not dry_run: staff.save() - stats['managers_linked'] += 1 + stats["managers_linked"] += 1 self.stdout.write(f" ✓ {row['name']} → {manager.name}") # Summary - self.stdout.write(f"\n{'='*50}") + self.stdout.write(f"\n{'=' * 50}") self.stdout.write("Summary:") self.stdout.write(f" Staff created: {stats['created']}") self.stdout.write(f" Staff updated: {stats['updated']}") @@ -145,90 +174,104 @@ class Command(BaseCommand): if dry_run: self.stdout.write(self.style.WARNING("\nDRY RUN - No changes made")) + # Backfill department name_ar for existing departments in DB + if translate_departments and dept_translations and not dry_run: + updated_depts = 0 + existing_depts = Department.objects.filter(hospital=hospital, name_ar="") + for dept in existing_depts: + if dept.name in dept_translations: + dept.name_ar = dept_translations[dept.name] + dept.save(update_fields=["name_ar"]) + updated_depts += 1 + if updated_depts: + self.stdout.write(f"\n Backfilled name_ar for {updated_depts} existing departments") + def parse_csv(self, csv_file): """Parse CSV and return list of dicts with bilingual support""" data = [] - with open(csv_file, 'r', encoding='utf-8') as f: + with open(csv_file, "r", encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: # Parse manager "ID - Name" manager_id = None - manager_name = '' - if row.get('Manager', '').strip(): - manager_parts = row['Manager'].split('-', 1) + manager_name = "" + if row.get("Manager", "").strip(): + manager_parts = row["Manager"].split("-", 1) manager_id = manager_parts[0].strip() - manager_name = manager_parts[1].strip() if len(manager_parts) > 1 else '' - + manager_name = manager_parts[1].strip() if len(manager_parts) > 1 else "" + # Parse name - name = row.get('Name', '').strip() + name = row.get("Name", "").strip() parts = name.split(None, 1) - + # Parse Arabic name - name_ar = row.get('Name_ar', '').strip() - parts_ar = name_ar.split(None, 1) if name_ar else ['', ''] - - data.append({ - 'staff_id': row.get('Staff ID', '').strip(), - 'name': name, - 'name_ar': name_ar, - 'first_name': parts[0] if parts else name, - 'last_name': parts[1] if len(parts) > 1 else '', - 'first_name_ar': parts_ar[0] if parts_ar else '', - 'last_name_ar': parts_ar[1] if len(parts_ar) > 1 else '', - 'civil_id': row.get('Civil Identity Number', '').strip(), - 'location': row.get('Location', '').strip(), - 'location_ar': row.get('Location_ar', '').strip(), - 'department': row.get('Department', '').strip(), - 'department_ar': row.get('Department_ar', '').strip(), - 'section': row.get('Section', '').strip(), - 'section_ar': row.get('Section_ar', '').strip(), - 'subsection': row.get('Subsection', '').strip(), - 'subsection_ar': row.get('Subsection_ar', '').strip(), - 'job_title': row.get('AlHammadi Job Title', '').strip(), - 'job_title_ar': row.get('AlHammadi Job Title_ar', '').strip(), - 'country': row.get('Country', '').strip(), - 'country_ar': row.get('Country_ar', '').strip(), - 'gender': row.get('Gender', '').strip().lower() if row.get('Gender') else '', - 'manager_id': manager_id, - 'manager_name': manager_name, - }) + name_ar = row.get("Name_ar", "").strip() + parts_ar = name_ar.split(None, 1) if name_ar else ["", ""] + + data.append( + { + "staff_id": row.get("Staff ID", "").strip(), + "name": name, + "name_ar": name_ar, + "first_name": parts[0] if parts else name, + "last_name": parts[1] if len(parts) > 1 else "", + "first_name_ar": parts_ar[0] if parts_ar else "", + "last_name_ar": parts_ar[1] if len(parts_ar) > 1 else "", + "civil_id": row.get("Civil Identity Number", "").strip(), + "location": row.get("Location", "").strip(), + "location_ar": row.get("Location_ar", "").strip(), + "department": row.get("Department", "").strip(), + "department_ar": row.get("Department_ar", "").strip(), + "section": row.get("Section", "").strip(), + "section_ar": row.get("Section_ar", "").strip(), + "subsection": row.get("Subsection", "").strip(), + "subsection_ar": row.get("Subsection_ar", "").strip(), + "job_title": row.get("AlHammadi Job Title", "").strip(), + "job_title_ar": row.get("AlHammadi Job Title_ar", "").strip(), + "country": row.get("Country", "").strip(), + "country_ar": row.get("Country_ar", "").strip(), + "gender": row.get("Gender", "").strip().lower() if row.get("Gender") else "", + "manager_id": manager_id, + "manager_name": manager_name, + } + ) return data def _get_or_create_department(self, hospital, dept_name, dept_name_ar, cache, dry_run, stats): """Get or create department (top-level only)""" if not dept_name: return None - + cache_key = (str(hospital.id), dept_name) - + if cache_key in cache: return cache[cache_key] - + # Get or create main department (top-level, parent=None) dept, created = Department.objects.get_or_create( hospital=hospital, name__iexact=dept_name, parent__isnull=True, # Only match top-level departments defaults={ - 'name': dept_name, - 'name_ar': dept_name_ar or '', - 'code': str(uuid.uuid4())[:8], - 'status': 'active' - } + "name": dept_name, + "name_ar": dept_name_ar or "", + "code": str(uuid.uuid4())[:8], + "status": "active", + }, ) if created and not dry_run: - stats['depts_created'] += 1 + stats["depts_created"] += 1 self.stdout.write(f" + Created department: {dept_name}") elif created and dry_run: - stats['depts_created'] += 1 + stats["depts_created"] += 1 self.stdout.write(f" + Would create department: {dept_name}") - + # Update Arabic name if empty and we have new data if dept.name_ar != dept_name_ar and dept_name_ar: dept.name_ar = dept_name_ar if not dry_run: dept.save() - + cache[cache_key] = dept return dept @@ -236,42 +279,42 @@ class Command(BaseCommand): """Get or create StaffSection within a department""" if not section_name or not department: return None - + cache_key = (str(department.id), section_name) - + if cache_key in cache: return cache[cache_key] - + # If section name is same as department (case-insensitive), skip if section_name.lower() == department.name.lower(): self.stdout.write(f" ! Section name '{section_name}' same as department, skipping section") cache[cache_key] = None return None - + # Get or create section section, created = StaffSection.objects.get_or_create( department=department, name__iexact=section_name, defaults={ - 'name': section_name, - 'name_ar': section_name_ar or '', - 'code': str(uuid.uuid4())[:8], - 'status': 'active' - } + "name": section_name, + "name_ar": section_name_ar or "", + "code": str(uuid.uuid4())[:8], + "status": "active", + }, ) if created and not dry_run: - stats['sections_created'] += 1 + stats["sections_created"] += 1 self.stdout.write(f" + Created section: {section_name} (under {department.name})") elif created and dry_run: - stats['sections_created'] += 1 + stats["sections_created"] += 1 self.stdout.write(f" + Would create section: {section_name} (under {department.name})") - + # Update Arabic name if empty and we have new data if section.name_ar != section_name_ar and section_name_ar: section.name_ar = section_name_ar if not dry_run: section.save() - + cache[cache_key] = section return section @@ -279,101 +322,149 @@ class Command(BaseCommand): """Get or create StaffSubsection within a section""" if not subsection_name or not section: return None - + cache_key = (str(section.id), subsection_name) - + if cache_key in cache: return cache[cache_key] - + # If subsection name is same as section (case-insensitive), skip if subsection_name.lower() == section.name.lower(): self.stdout.write(f" ! Subsection name '{subsection_name}' same as section, skipping subsection") cache[cache_key] = None return None - + # Get or create subsection subsection, created = StaffSubsection.objects.get_or_create( section=section, name__iexact=subsection_name, defaults={ - 'name': subsection_name, - 'name_ar': subsection_name_ar or '', - 'code': str(uuid.uuid4())[:8], - 'status': 'active' - } + "name": subsection_name, + "name_ar": subsection_name_ar or "", + "code": str(uuid.uuid4())[:8], + "status": "active", + }, ) if created and not dry_run: - stats['subsections_created'] = stats.get('subsections_created', 0) + 1 + stats["subsections_created"] = stats.get("subsections_created", 0) + 1 self.stdout.write(f" + Created subsection: {subsection_name} (under {section.name})") elif created and dry_run: - stats['subsections_created'] = stats.get('subsections_created', 0) + 1 + stats["subsections_created"] = stats.get("subsections_created", 0) + 1 self.stdout.write(f" + Would create subsection: {subsection_name} (under {section.name})") - + # Update Arabic name if empty and we have new data if subsection.name_ar != subsection_name_ar and subsection_name_ar: subsection.name_ar = subsection_name_ar if not dry_run: subsection.save() - + cache[cache_key] = subsection return subsection def _create_staff(self, row, hospital, department, section, subsection, staff_type): """Create new Staff record with bilingual data""" return Staff( - employee_id=row['staff_id'], - name=row['name'], - name_ar=row['name_ar'], - first_name=row['first_name'], - last_name=row['last_name'], - first_name_ar=row['first_name_ar'], - last_name_ar=row['last_name_ar'], - civil_id=row['civil_id'], + employee_id=row["staff_id"], + name=row["name"], + name_ar=row["name_ar"], + first_name=row["first_name"], + last_name=row["last_name"], + first_name_ar=row["first_name_ar"], + last_name_ar=row["last_name_ar"], + civil_id=row["civil_id"], staff_type=staff_type, - job_title=row['job_title'], - job_title_ar=row['job_title_ar'], - specialization=row['job_title'], + job_title=row["job_title"], + job_title_ar=row["job_title_ar"], + specialization=row["job_title"], hospital=hospital, department=department, section_fk=section, # ForeignKey to StaffSection subsection_fk=subsection, # ForeignKey to StaffSubsection - department_name=row['department'], - department_name_ar=row['department_ar'], - section=row['section'], # Original CSV value - section_ar=row['section_ar'], - subsection=row['subsection'], # Original CSV value - subsection_ar=row['subsection_ar'], - location=row['location'], - location_ar=row['location_ar'], - country=row['country'], - country_ar=row['country_ar'], - gender=row['gender'], - status='active' + department_name=row["department"], + department_name_ar=row["department_ar"], + section=row["section"], # Original CSV value + section_ar=row["section_ar"], + subsection=row["subsection"], # Original CSV value + subsection_ar=row["subsection_ar"], + location=row["location"], + location_ar=row["location_ar"], + country=row["country"], + country_ar=row["country_ar"], + gender=row["gender"], + status="active", ) def _update_staff(self, staff, row, hospital, department, section, subsection, staff_type): """Update existing Staff record with bilingual data""" - staff.name = row['name'] - staff.name_ar = row['name_ar'] - staff.first_name = row['first_name'] - staff.last_name = row['last_name'] - staff.first_name_ar = row['first_name_ar'] - staff.last_name_ar = row['last_name_ar'] - staff.civil_id = row['civil_id'] - staff.job_title = row['job_title'] - staff.job_title_ar = row['job_title_ar'] + staff.name = row["name"] + staff.name_ar = row["name_ar"] + staff.first_name = row["first_name"] + staff.last_name = row["last_name"] + staff.first_name_ar = row["first_name_ar"] + staff.last_name_ar = row["last_name_ar"] + staff.civil_id = row["civil_id"] + staff.job_title = row["job_title"] + staff.job_title_ar = row["job_title_ar"] staff.hospital = hospital staff.department = department staff.section_fk = section # ForeignKey to StaffSection staff.subsection_fk = subsection # ForeignKey to StaffSubsection - staff.department_name = row['department'] - staff.department_name_ar = row['department_ar'] - staff.section = row['section'] # Original CSV value - staff.section_ar = row['section_ar'] - staff.subsection = row['subsection'] # Original CSV value - staff.subsection_ar = row['subsection_ar'] - staff.location = row['location'] - staff.location_ar = row['location_ar'] - staff.country = row['country'] - staff.country_ar = row['country_ar'] - staff.gender = row['gender'] + staff.department_name = row["department"] + staff.department_name_ar = row["department_ar"] + staff.section = row["section"] # Original CSV value + staff.section_ar = row["section_ar"] + staff.subsection = row["subsection"] # Original CSV value + staff.subsection_ar = row["subsection_ar"] + staff.location = row["location"] + staff.location_ar = row["location_ar"] + staff.country = row["country"] + staff.country_ar = row["country_ar"] + staff.gender = row["gender"] + + def _translate_department_names(self, staff_data: List[dict]) -> Dict[str, str]: + """Translate unique department names from English to Arabic using AI.""" + unique_dept_names = list({row["department"] for row in staff_data if row["department"]}) + + if not unique_dept_names: + return {} + + names_json = json.dumps(unique_dept_names, ensure_ascii=False) + prompt = ( + "Translate the following hospital department names from English to Arabic. " + "Return a single JSON object where each key is the original English name " + "and the value is the Arabic translation. " + "Do NOT include any explanation or markdown, only the JSON object.\n\n" + f"Department names: {names_json}" + ) + + try: + from apps.core.ai_service import AIService + + response = AIService.chat_completion( + prompt=prompt, + system_prompt=( + "You are a professional Arabic translator specializing in " + "healthcare and hospital organizational terminology. " + "Only return valid JSON, nothing else." + ), + response_format="json_object", + ) + + cleaned = response.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + cleaned = cleaned.strip() + + translations = json.loads(cleaned) + return translations + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse AI translation response as JSON: {e}") + self.stdout.write(self.style.ERROR(f" JSON parse error: {e}")) + return {} + except Exception as e: + logger.error(f"AI translation failed: {e}") + self.stdout.write(self.style.ERROR(f" Translation error: {e}")) + return {} diff --git a/apps/organizations/models.py b/apps/organizations/models.py index 9935f67..e8eb6d8 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -1,6 +1,7 @@ """ Organizations models - Hospital, Department, Physician, Employee, Patient """ + from django.db import models from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices @@ -8,6 +9,7 @@ from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices class Organization(UUIDModel, TimeStampedModel): """Top-level healthcare organization/company""" + name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") code = models.CharField(max_length=50, unique=True, db_index=True) @@ -19,22 +21,17 @@ class Organization(UUIDModel, TimeStampedModel): city = models.CharField(max_length=100, blank=True) # Status - status = models.CharField( - max_length=20, - choices=StatusChoices.choices, - default=StatusChoices.ACTIVE, - db_index=True - ) + status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) # Branding and metadata - logo = models.ImageField(upload_to='organizations/logos/', null=True, blank=True) + logo = models.ImageField(upload_to="organizations/logos/", null=True, blank=True) website = models.URLField(blank=True) license_number = models.CharField(max_length=100, blank=True) class Meta: - ordering = ['name'] - verbose_name = 'Organization' - verbose_name_plural = 'Organizations' + ordering = ["name"] + verbose_name = "Organization" + verbose_name_plural = "Organizations" def __str__(self): return self.name @@ -42,13 +39,14 @@ class Organization(UUIDModel, TimeStampedModel): class Hospital(UUIDModel, TimeStampedModel): """Hospital/Facility model""" + organization = models.ForeignKey( Organization, on_delete=models.CASCADE, null=True, blank=True, - related_name='hospitals', - help_text="Parent organization (null for backward compatibility)" + related_name="hospitals", + help_text="Parent organization (null for backward compatibility)", ) name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") @@ -61,49 +59,44 @@ class Hospital(UUIDModel, TimeStampedModel): email = models.EmailField(blank=True) # Status - status = models.CharField( - max_length=20, - choices=StatusChoices.choices, - default=StatusChoices.ACTIVE, - db_index=True - ) + status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) # Executive leadership ceo = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='hospitals_as_ceo', - verbose_name='CEO', - help_text="Chief Executive Officer" + related_name="hospitals_as_ceo", + verbose_name="CEO", + help_text="Chief Executive Officer", ) medical_director = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='hospitals_as_medical_director', - verbose_name='Medical Director', - help_text="Medical Director" + related_name="hospitals_as_medical_director", + verbose_name="Medical Director", + help_text="Medical Director", ) coo = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='hospitals_as_coo', - verbose_name='COO', - help_text="Chief Operating Officer" + related_name="hospitals_as_coo", + verbose_name="COO", + help_text="Chief Operating Officer", ) cfo = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='hospitals_as_cfo', - verbose_name='CFO', - help_text="Chief Financial Officer" + related_name="hospitals_as_cfo", + verbose_name="CFO", + help_text="Chief Financial Officer", ) # Metadata @@ -112,36 +105,28 @@ class Hospital(UUIDModel, TimeStampedModel): metadata = models.JSONField(default=dict, blank=True, help_text="Hospital configuration settings") class Meta: - ordering = ['name'] - verbose_name_plural = 'Hospitals' + ordering = ["name"] + verbose_name_plural = "Hospitals" def __str__(self): return self.name + class Department(UUIDModel, TimeStampedModel): """Department within a hospital""" - hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='departments') + + hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name="departments") name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") code = models.CharField(max_length=50, db_index=True) # Hierarchy - parent = models.ForeignKey( - 'self', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='sub_departments' - ) + parent = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="sub_departments") # Manager manager = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='managed_departments' + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="managed_departments" ) # Contact @@ -150,16 +135,11 @@ class Department(UUIDModel, TimeStampedModel): location = models.CharField(max_length=200, blank=True, help_text="Building/Floor/Room") # Status - status = models.CharField( - max_length=20, - choices=StatusChoices.choices, - default=StatusChoices.ACTIVE, - db_index=True - ) + status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) class Meta: - ordering = ['hospital', 'name'] - unique_together = [['hospital', 'code']] + ordering = ["hospital", "name"] + unique_together = [["hospital", "code"]] def __str__(self): return f"{self.name}" @@ -167,17 +147,14 @@ class Department(UUIDModel, TimeStampedModel): class Staff(UUIDModel, TimeStampedModel): class StaffType(models.TextChoices): - PHYSICIAN = 'physician', 'Physician' - NURSE = 'nurse', 'Nurse' - ADMIN = 'admin', 'Administrative' - OTHER = 'other', 'Other' + PHYSICIAN = "physician", "Physician" + NURSE = "nurse", "Nurse" + ADMIN = "admin", "Administrative" + OTHER = "other", "Other" # Link to User (Keep it optional for external/temp staff) user = models.OneToOneField( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, blank=True, - related_name='staff_profile' + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="staff_profile" ) # Unified Identity (AI will search these 4 fields) @@ -188,7 +165,7 @@ class Staff(UUIDModel, TimeStampedModel): # Role Logic staff_type = models.CharField(max_length=20, choices=StaffType.choices) - job_title = models.CharField(max_length=200) # "Cardiologist", "Senior Nurse", etc. + job_title = models.CharField(max_length=200) # "Cardiologist", "Senior Nurse", etc. # Professional Data (Nullable for non-physicians) license_number = models.CharField(max_length=100, unique=True, null=True, blank=True) @@ -202,8 +179,8 @@ class Staff(UUIDModel, TimeStampedModel): name_ar = models.CharField(max_length=300, blank=True, verbose_name="Full Name (Arabic)") # Organization - hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='staff') - department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='staff') + hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name="staff") + department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name="staff") # Additional fields from CSV import civil_id = models.CharField(max_length=50, blank=True, db_index=True, verbose_name="Civil Identity Number") @@ -212,55 +189,56 @@ class Staff(UUIDModel, TimeStampedModel): location = models.CharField(max_length=200, blank=True, verbose_name="Location") location_ar = models.CharField(max_length=200, blank=True, verbose_name="Location (Arabic)") gender = models.CharField( - max_length=10, - choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], - blank=True + max_length=10, choices=[("male", "Male"), ("female", "Female"), ("other", "Other")], blank=True ) department_name = models.CharField(max_length=200, blank=True, verbose_name="Department (Original)") department_name_ar = models.CharField(max_length=200, blank=True, verbose_name="Department (Arabic)") - + # Section and Subsection (CharFields for storing original CSV values) section = models.CharField(max_length=200, blank=True, verbose_name="Section") section_ar = models.CharField(max_length=200, blank=True, verbose_name="Section (Arabic)") subsection = models.CharField(max_length=200, blank=True, verbose_name="Subsection") subsection_ar = models.CharField(max_length=200, blank=True, verbose_name="Subsection (Arabic)") - + # ForeignKeys to Section and Subsection models section_fk = models.ForeignKey( - 'StaffSection', + "StaffSection", on_delete=models.SET_NULL, null=True, blank=True, - related_name='staff_members', - verbose_name="Section (FK)" + related_name="staff_members", + verbose_name="Section (FK)", ) subsection_fk = models.ForeignKey( - 'StaffSubsection', + "StaffSubsection", on_delete=models.SET_NULL, null=True, blank=True, - related_name='staff_members', - verbose_name="Subsection (FK)" + related_name="staff_members", + verbose_name="Subsection (FK)", ) - + job_title_ar = models.CharField(max_length=200, blank=True, verbose_name="Job Title (Arabic)") # Self-referential manager field for hierarchy report_to = models.ForeignKey( - 'self', + "self", on_delete=models.SET_NULL, null=True, blank=True, - related_name='direct_reports', - verbose_name="Reports To" + related_name="direct_reports", + verbose_name="Reports To", ) # Head of department/section/subsection indicator is_head = models.BooleanField(default=False, verbose_name="Is Head") # Physician indicator - set to True when staff comes from physician rating import - physician = models.BooleanField(default=False, verbose_name="Is Physician", - help_text="Set to True when staff record comes from physician rating import") + physician = models.BooleanField( + default=False, + verbose_name="Is Physician", + help_text="Set to True when staff record comes from physician rating import", + ) status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE) @@ -286,6 +264,7 @@ class Staff(UUIDModel, TimeStampedModel): parts.append(self.department_name) return " - ".join(parts) + # TODO Add Section # class Physician(UUIDModel, TimeStampedModel): # """Physician/Doctor model""" @@ -380,6 +359,7 @@ class Staff(UUIDModel, TimeStampedModel): class Patient(UUIDModel, TimeStampedModel): """Patient model""" + # Basic information mrn = models.CharField(max_length=50, unique=True, db_index=True, verbose_name="Medical Record Number") national_id = models.CharField(max_length=50, blank=True, db_index=True) @@ -392,10 +372,9 @@ class Patient(UUIDModel, TimeStampedModel): # Demographics date_of_birth = models.DateField(null=True, blank=True) gender = models.CharField( - max_length=10, - choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], - blank=True + max_length=10, choices=[("male", "Male"), ("female", "Female"), ("other", "Other")], blank=True ) + nationality = models.CharField(max_length=100, blank=True, db_index=True) # Contact phone = models.CharField(max_length=20, blank=True) @@ -405,23 +384,14 @@ class Patient(UUIDModel, TimeStampedModel): # Primary hospital primary_hospital = models.ForeignKey( - Hospital, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='patients' + Hospital, on_delete=models.SET_NULL, null=True, blank=True, related_name="patients" ) # Status - status = models.CharField( - max_length=20, - choices=StatusChoices.choices, - default=StatusChoices.ACTIVE, - db_index=True - ) + status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) class Meta: - ordering = ['last_name', 'first_name'] + ordering = ["last_name", "first_name"] def __str__(self): return f"{self.first_name} {self.last_name} (MRN: {self.mrn})" @@ -442,7 +412,7 @@ class Patient(UUIDModel, TimeStampedModel): from datetime import datetime # Generate MRN with date prefix for better traceability - date_prefix = datetime.now().strftime('%Y%m%d') + date_prefix = datetime.now().strftime("%Y%m%d") random_suffix = random.randint(100000, 999999) mrn = f"PTN-{date_prefix}-{random_suffix}" @@ -456,72 +426,54 @@ class Patient(UUIDModel, TimeStampedModel): class StaffSection(UUIDModel, TimeStampedModel): """Section within a department (for staff organization)""" - department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name='sections') - + + department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name="sections") + name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") code = models.CharField(max_length=50, blank=True) - + # Manager - head = models.ForeignKey( - 'Staff', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='headed_sections' - ) - + head = models.ForeignKey("Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="headed_sections") + # Status - status = models.CharField( - max_length=20, - choices=StatusChoices.choices, - default=StatusChoices.ACTIVE, - db_index=True - ) - + status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) + class Meta: - ordering = ['department', 'name'] - unique_together = [['department', 'name']] - + ordering = ["department", "name"] + unique_together = [["department", "name"]] + def __str__(self): return f"{self.department.name} - {self.name}" class StaffSubsection(UUIDModel, TimeStampedModel): """Subsection within a section (for staff organization)""" - section = models.ForeignKey(StaffSection, on_delete=models.CASCADE, related_name='subsections') - + + section = models.ForeignKey(StaffSection, on_delete=models.CASCADE, related_name="subsections") + name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") code = models.CharField(max_length=50, blank=True) - + # Manager head = models.ForeignKey( - 'Staff', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='headed_subsections' + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="headed_subsections" ) - + # Status - status = models.CharField( - max_length=20, - choices=StatusChoices.choices, - default=StatusChoices.ACTIVE, - db_index=True - ) - + status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) + class Meta: - ordering = ['section', 'name'] - unique_together = [['section', 'name']] - + ordering = ["section", "name"] + unique_together = [["section", "name"]] + def __str__(self): return f"{self.section.department.name} - {self.section.name} - {self.name}" class Location(models.Model): - id = models.IntegerField(primary_key=True) # Using your specific IDs (48, 49, etc.) + id = models.IntegerField(primary_key=True) # Using your specific IDs (48, 49, etc.) name_ar = models.CharField(max_length=100) name_en = models.CharField(max_length=100) @@ -529,8 +481,9 @@ class Location(models.Model): # Prefer English name if available, otherwise use Arabic return self.name_en if self.name_en else self.name_ar + class MainSection(models.Model): - id = models.IntegerField(primary_key=True) # Using your specific IDs (1, 2, 3, 4, 5) + id = models.IntegerField(primary_key=True) # Using your specific IDs (1, 2, 3, 4, 5) name_ar = models.CharField(max_length=100) name_en = models.CharField(max_length=100) @@ -538,12 +491,13 @@ class MainSection(models.Model): # Prefer English name if available, otherwise use Arabic return self.name_en if self.name_en else self.name_ar + class SubSection(models.Model): - internal_id = models.IntegerField(primary_key=True) # The 'value' from HTML + internal_id = models.IntegerField(primary_key=True) # The 'value' from HTML name_ar = models.CharField(max_length=255) name_en = models.CharField(max_length=255) - location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name='subsections') - main_section = models.ForeignKey(MainSection, on_delete=models.CASCADE, related_name='subsections') + location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name="subsections") + main_section = models.ForeignKey(MainSection, on_delete=models.CASCADE, related_name="subsections") def __str__(self): # Prefer English name if available, otherwise use Arabic diff --git a/apps/organizations/ui_views.py b/apps/organizations/ui_views.py index 70d65c8..70f7fcd 100644 --- a/apps/organizations/ui_views.py +++ b/apps/organizations/ui_views.py @@ -1,8 +1,11 @@ +from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Q from django.shortcuts import render, redirect, get_object_or_404 from django.contrib import messages +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt from apps.core.decorators import block_source_user, hospital_admin_required @@ -324,20 +327,23 @@ def patient_list(request): """Patients list view""" queryset = Patient.objects.select_related("primary_hospital") - # Apply RBAC filters + # Filter by current hospital context user = request.user - if not user.is_px_admin() and user.hospital: - queryset = queryset.filter(primary_hospital=user.hospital) - - # Apply filters - hospital_filter = request.GET.get("hospital") - if hospital_filter: - queryset = queryset.filter(primary_hospital_id=hospital_filter) + if request.tenant_hospital: + queryset = queryset.filter(primary_hospital=request.tenant_hospital) status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) + gender_filter = request.GET.get("gender") + if gender_filter: + queryset = queryset.filter(gender=gender_filter) + + nationality_filter = request.GET.get("nationality") + if nationality_filter: + queryset = queryset.filter(nationality=nationality_filter) + # Search search_query = request.GET.get("search") if search_query: @@ -350,7 +356,59 @@ def patient_list(request): ) # Ordering - queryset = queryset.order_by("last_name", "first_name") + order_by = request.GET.get("order_by", "last_name") + if order_by not in ["last_name", "-last_name", "-created_at", "created_at"]: + order_by = "last_name" + queryset = queryset.order_by(order_by, "first_name") + + # Stats (computed from filtered queryset) + stats = { + "total": queryset.count(), + "active": queryset.filter(status="active").count(), + "hospitals": queryset.values("primary_hospital").distinct().count(), + "visits": 0, + } + + # Export CSV + if request.GET.get("export") == "csv": + import csv + from django.http import HttpResponse + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="patients.csv"' + writer = csv.writer(response) + writer.writerow( + [ + "MRN", + "First Name", + "Last Name", + "National ID", + "Gender", + "Nationality", + "Phone", + "Email", + "Hospital", + "Status", + "Created At", + ] + ) + for p in queryset[:10000]: + writer.writerow( + [ + p.mrn, + p.first_name, + p.last_name, + p.national_id, + p.get_gender_display(), + p.nationality, + p.phone, + p.email, + p.primary_hospital.name if p.primary_hospital else "", + p.get_status_display(), + p.created_at.strftime("%Y-%m-%d %H:%M"), + ] + ) + return response # Pagination page_size = int(request.GET.get("page_size", 25)) @@ -358,16 +416,17 @@ def patient_list(request): page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) - # Get hospitals for filter - hospitals = Hospital.objects.filter(status="active") - if not user.is_px_admin() and user.hospital: - hospitals = hospitals.filter(id=user.hospital.id) + # Get nationalities for filter + nationalities = ( + Patient.objects.exclude(nationality="").values_list("nationality", flat=True).order_by("nationality").distinct() + ) context = { "page_obj": page_obj, "patients": page_obj.object_list, - "hospitals": hospitals, + "nationalities": nationalities, "filters": request.GET, + "stats": stats, } return render(request, "organizations/patient_list.html", context) @@ -1157,21 +1216,79 @@ def patient_detail(request, pk): return HttpResponseForbidden("You don't have permission to view this patient") - # Get patient's survey history + from apps.integrations.models import HISPatientVisit from apps.surveys.models import SurveyInstance - surveys = ( - SurveyInstance.objects.filter(patient=patient).select_related("survey_template").order_by("-created_at")[:10] + tab = request.GET.get("tab", "visits") + + his_visits = ( + HISPatientVisit.objects.filter(patient=patient) + .select_related("hospital", "survey_instance", "primary_doctor_fk", "consultant_fk") + .order_by("-admit_date")[:20] ) + surveys = ( + SurveyInstance.objects.filter(patient=patient) + .select_related("survey_template") + .prefetch_related("responses") + .order_by("-created_at")[:10] + ) + + complaints = patient.complaints.select_related("hospital", "department").order_by("-created_at")[:10] + + inquiries = patient.inquiries.select_related("hospital", "department").order_by("-created_at")[:10] + + stats = { + "visits": HISPatientVisit.objects.filter(patient=patient).count(), + "surveys": SurveyInstance.objects.filter(patient=patient).count(), + "complaints": patient.complaints.count(), + "inquiries": patient.inquiries.count(), + } + context = { "patient": patient, + "tab": tab, + "his_visits": his_visits, "surveys": surveys, + "complaints": complaints, + "inquiries": inquiries, + "stats": stats, } return render(request, "organizations/patient_detail.html", context) +@block_source_user +@login_required +def patient_visit_journey(request, patient_pk, visit_pk): + """Patient visit journey timeline view""" + from apps.integrations.models import HISPatientVisit + + patient = get_object_or_404(Patient.objects.select_related("primary_hospital"), pk=patient_pk) + visit = get_object_or_404( + HISPatientVisit.objects.prefetch_related("visit_events").select_related( + "hospital", "survey_instance", "primary_doctor_fk", "consultant_fk" + ), + pk=visit_pk, + patient=patient, + ) + + user = request.user + if not user.is_px_admin() and patient.primary_hospital != user.hospital: + from django.http import HttpResponseForbidden + + return HttpResponseForbidden("You don't have permission to view this visit") + + timeline = list(visit.visit_events.all()) + + context = { + "patient": patient, + "visit": visit, + "timeline": timeline, + } + return render(request, "organizations/patient_visit_journey.html", context) + + @block_source_user @login_required def patient_create(request): @@ -1268,3 +1385,160 @@ def patient_delete(request, pk): } return render(request, "organizations/patient_confirm_delete.html", context) + + +@csrf_exempt +@login_required +@require_POST +def search_his_patient(request): + """Search HIS by SSN or MobileNo, return patient demographics as JSON.""" + import json + from django.http import JsonResponse + + try: + data = json.loads(request.body or b"{}") + if not isinstance(data, dict): + data = {} + except (json.JSONDecodeError, TypeError): + data = {} + + ssn = (data.get("ssn") or "").strip() + mobile_no = (data.get("mobile_no") or "").strip() + + if not ssn and not mobile_no: + return JsonResponse({"error": "Provide SSN or Mobile number"}, status=400) + + from apps.integrations.models import IntegrationConfig + from apps.integrations.services.his_client import HISClient + from django.conf import settings + + config = IntegrationConfig.objects.filter(source_system__in=["his", "other"]).first() + + if not config: + return JsonResponse({"error": "HIS integration not configured"}, status=400) + + if settings.DEBUG: + config.api_url = request.build_absolute_uri("/api/integrations/test-his-data/") + + client = HISClient(config) + patients = client.fetch_patient_by_identifier(ssn=ssn or None, mobile_no=mobile_no or None) + + if patients is None: + return JsonResponse({"error": "Failed to connect to HIS"}, status=500) + + if not patients: + return JsonResponse({"patients": [], "total": 0}) + + result = [] + for p in patients: + result.append( + { + "patient_id": p.get("PatientID"), + "admission_id": p.get("AdmissionID"), + "name": p.get("PatientName", ""), + "ssn": p.get("SSN", ""), + "mobile_no": p.get("MobileNo", ""), + "gender": p.get("Gender", ""), + "nationality": p.get("PatientNationality", ""), + "dob": p.get("DOB", ""), + "patient_type": p.get("PatientType", ""), + "hospital_name": p.get("HospitalName", ""), + "hospital_id": p.get("HospitalID", ""), + "admit_date": p.get("AdmitDate", ""), + "discharge_date": p.get("DischargeDate", ""), + "reg_code": p.get("RegCode", ""), + "raw": p, + } + ) + + return JsonResponse({"patients": result, "total": len(result)}) + + +@csrf_exempt +@login_required +@require_POST +def save_his_patient(request): + """Save a patient from HIS data locally.""" + import json + from django.http import JsonResponse + + try: + data = json.loads(request.body or b"{}") + if not isinstance(data, dict): + data = {} + except (json.JSONDecodeError, TypeError): + data = {} + + patient_data = data.get("patient_data") + if not patient_data: + return JsonResponse({"error": "No patient data provided"}, status=400) + + from apps.integrations.models import IntegrationConfig + from apps.integrations.services.his_adapter import HISAdapter + + config = IntegrationConfig.objects.filter(source_system__in=["his", "other"]).first() + + if not config: + return JsonResponse({"error": "HIS integration not configured"}, status=400) + + try: + hospital = HISAdapter.get_or_create_hospital(patient_data) + patient = HISAdapter.get_or_create_patient(patient_data, hospital) + visit = HISAdapter.save_patient_visit( + patient=patient, + hospital=hospital, + patient_data=patient_data, + visit_timeline=[], + is_visit_complete=False, + ) + + return JsonResponse( + { + "success": True, + "patient_id": str(patient.id), + "patient_name": patient.get_full_name(), + "visit_id": str(visit.id), + "admission_id": visit.admission_id, + } + ) + except Exception as e: + return JsonResponse({"error": str(e)}, status=500) + + +@block_source_user +@login_required +@require_POST +def send_complaint_link(request, pk): + """Create a complaint session and send SMS link to the patient.""" + from django.http import JsonResponse + from apps.complaints.models import PatientComplaintSession + from apps.notifications.services import NotificationService + + patient = get_object_or_404(Patient.objects.select_related("primary_hospital"), pk=pk) + + if not patient.phone: + return JsonResponse({"error": "Patient has no phone number"}, status=400) + + session = PatientComplaintSession.objects.create( + patient=patient, + created_by=request.user, + ) + + base_url = getattr(settings, "SURVEY_BASE_URL", "") + link = f"{base_url}/complaints/patient/{session.token}/" + + message = ( + f"Dear {patient.first_name}, we value your feedback. " + f"If you have any concerns about your recent visit, please submit them here: {link}" + ) + + NotificationService.send_sms(phone=patient.phone, message=message) + + return JsonResponse( + { + "success": True, + "token": session.token, + "link": link, + "phone": patient.phone, + } + ) diff --git a/apps/organizations/urls.py b/apps/organizations/urls.py index f822c7f..20e58d4 100644 --- a/apps/organizations/urls.py +++ b/apps/organizations/urls.py @@ -34,68 +34,73 @@ from .ui_views import ( subsection_delete, ) -app_name = 'organizations' +app_name = "organizations" router = DefaultRouter() -router.register(r'organizations', OrganizationViewSet, basename='organization-api') -router.register(r'hospitals', HospitalViewSet, basename='hospital-api') -router.register(r'departments', DepartmentViewSet, basename='department-api') -router.register(r'staff', StaffViewSet, basename='staff-api') -router.register(r'patients', PatientViewSet, basename='patient-api') -router.register(r'locations', LocationViewSet, basename='location-api') -router.register(r'main-sections', MainSectionViewSet, basename='main-section-api') -router.register(r'subsections', SubSectionViewSet, basename='subsection-api') +router.register(r"organizations", OrganizationViewSet, basename="organization-api") +router.register(r"hospitals", HospitalViewSet, basename="hospital-api") +router.register(r"departments", DepartmentViewSet, basename="department-api") +router.register(r"staff", StaffViewSet, basename="staff-api") +router.register(r"patients", PatientViewSet, basename="patient-api") +router.register(r"locations", LocationViewSet, basename="location-api") +router.register(r"main-sections", MainSectionViewSet, basename="main-section-api") +router.register(r"subsections", SubSectionViewSet, basename="subsection-api") urlpatterns = [ # UI Views (come first - more specific routes) - path('organizations/create/', ui_views.organization_create, name='organization_create'), - path('organizations//', ui_views.organization_detail, name='organization_detail'), - path('organizations/', ui_views.organization_list, name='organization_list'), - path('hospitals/', ui_views.hospital_list, name='hospital_list'), - path('departments/', ui_views.department_list, name='department_list'), - path('staff/create/', ui_views.staff_create, name='staff_create'), - path('staff//edit/', ui_views.staff_update, name='staff_update'), - path('staff//', ui_views.staff_detail, name='staff_detail'), - path('staff/hierarchy/d3/', ui_views.staff_hierarchy_d3, name='staff_hierarchy_d3'), - path('staff/hierarchy/', ui_views.staff_hierarchy, name='staff_hierarchy'), - path('staff/', ui_views.staff_list, name='staff_list'), - path('patients/', ui_views.patient_list, name='patient_list'), - path('patients/create/', ui_views.patient_create, name='patient_create'), - path('patients//', ui_views.patient_detail, name='patient_detail'), - path('patients//edit/', ui_views.patient_update, name='patient_update'), - path('patients//delete/', ui_views.patient_delete, name='patient_delete'), - + path("organizations/create/", ui_views.organization_create, name="organization_create"), + path("organizations//", ui_views.organization_detail, name="organization_detail"), + path("organizations/", ui_views.organization_list, name="organization_list"), + path("hospitals/", ui_views.hospital_list, name="hospital_list"), + path("departments/", ui_views.department_list, name="department_list"), + path("staff/create/", ui_views.staff_create, name="staff_create"), + path("staff//edit/", ui_views.staff_update, name="staff_update"), + path("staff//", ui_views.staff_detail, name="staff_detail"), + path("staff/hierarchy/d3/", ui_views.staff_hierarchy_d3, name="staff_hierarchy_d3"), + path("staff/hierarchy/", ui_views.staff_hierarchy, name="staff_hierarchy"), + path("staff/", ui_views.staff_list, name="staff_list"), + path("patients/", ui_views.patient_list, name="patient_list"), + path("patients/search-his/", ui_views.search_his_patient, name="search_his_patient"), + path("patients/save-his/", ui_views.save_his_patient, name="save_his_patient"), + path("patients/create/", ui_views.patient_create, name="patient_create"), + path("patients//", ui_views.patient_detail, name="patient_detail"), + path( + "patients//visits//", + ui_views.patient_visit_journey, + name="patient_visit_journey", + ), + path("patients//send-complaint-link/", ui_views.send_complaint_link, name="send_complaint_link"), + path("patients//edit/", ui_views.patient_update, name="patient_update"), + path("patients//delete/", ui_views.patient_delete, name="patient_delete"), # Department CRUD - path('departments/create/', department_create, name='department_create'), - path('departments//edit/', department_update, name='department_update'), - path('departments//delete/', department_delete, name='department_delete'), - + path("departments/create/", department_create, name="department_create"), + path("departments//edit/", department_update, name="department_update"), + path("departments//delete/", department_delete, name="department_delete"), # Section CRUD - path('sections/', section_list, name='section_list'), - path('sections/create/', section_create, name='section_create'), - path('sections//edit/', section_update, name='section_update'), - path('sections//delete/', section_delete, name='section_delete'), - + path("sections/", section_list, name="section_list"), + path("sections/create/", section_create, name="section_create"), + path("sections//edit/", section_update, name="section_update"), + path("sections//delete/", section_delete, name="section_delete"), # Subsection CRUD - path('subsections/', subsection_list, name='subsection_list'), - path('subsections/create/', subsection_create, name='subsection_create'), - path('subsections//edit/', subsection_update, name='subsection_update'), - path('subsections//delete/', subsection_delete, name='subsection_delete'), - + path("subsections/", subsection_list, name="subsection_list"), + path("subsections/create/", subsection_create, name="subsection_create"), + path("subsections//edit/", subsection_update, name="subsection_update"), + path("subsections//delete/", subsection_delete, name="subsection_delete"), # API Routes for complaint form dropdowns (public access) - path('dropdowns/locations/', api_location_list, name='api_location_list'), - path('dropdowns/main-sections/', api_main_section_list, name='api_main_section_list'), - path('dropdowns/subsections/', api_subsection_list, name='api_subsection_list'), - + path("dropdowns/locations/", api_location_list, name="api_location_list"), + path("dropdowns/main-sections/", api_main_section_list, name="api_main_section_list"), + path("dropdowns/subsections/", api_subsection_list, name="api_subsection_list"), # AJAX Routes for cascading dropdowns in complaint form - path('ajax/main-sections/', ajax_main_sections, name='ajax_main_sections'), - path('ajax/subsections/', ajax_subsections, name='ajax_subsections'), - path('ajax/departments/', ajax_departments, name='ajax_departments'), - + path("ajax/main-sections/", ajax_main_sections, name="ajax_main_sections"), + path("ajax/subsections/", ajax_subsections, name="ajax_subsections"), + path("ajax/departments/", ajax_departments, name="ajax_departments"), # Staff Hierarchy API (for D3 visualization) - path('api/staff/hierarchy/', api_staff_hierarchy, name='api_staff_hierarchy'), - path('api/staff/hierarchy//children/', api_staff_hierarchy_children, name='api_staff_hierarchy_children'), - + path("api/staff/hierarchy/", api_staff_hierarchy, name="api_staff_hierarchy"), + path( + "api/staff/hierarchy//children/", + api_staff_hierarchy_children, + name="api_staff_hierarchy_children", + ), # API Routes (must come last - catches anything not matched above) - path('api/', include(router.urls)), + path("api/", include(router.urls)), ] diff --git a/apps/physicians/adapter.py b/apps/physicians/adapter.py index 4435a1e..429217b 100644 --- a/apps/physicians/adapter.py +++ b/apps/physicians/adapter.py @@ -6,6 +6,7 @@ Handles the transformation of Doctor Rating data from HIS/CSV to internal format - Matches doctors to existing Staff records - Creates individual ratings and aggregates monthly """ + import logging import re from datetime import datetime @@ -30,33 +31,33 @@ class DoctorRatingAdapter: def parse_doctor_name(doctor_name_raw: str) -> Tuple[str, str]: """ Parse doctor name from HIS format. - + HIS Format: "10738-OMAYMAH YAQOUB ELAMEIAN" Returns: (doctor_id, doctor_name_clean) - + Examples: - "10738-OMAYMAH YAQOUB ELAMEIAN" -> ("10738", "OMAYMAH YAQOUB ELAMEIAN") - "OMAYMAH YAQOUB ELAMEIAN" -> ("", "OMAYMAH YAQOUB ELAMEIAN") """ if not doctor_name_raw: return "", "" - + doctor_name_raw = doctor_name_raw.strip() - + # Pattern: ID-NAME (e.g., "10738-OMAYMAH YAQOUB ELAMEIAN") - match = re.match(r'^(\d+)-(.+)$', doctor_name_raw) + match = re.match(r"^(\d+)-(.+)$", doctor_name_raw) if match: doctor_id = match.group(1) doctor_name = match.group(2).strip() return doctor_id, doctor_name - + # Pattern: ID - NAME (with spaces) - match = re.match(r'^(\d+)\s*-\s*(.+)$', doctor_name_raw) + match = re.match(r"^(\d+)\s*-\s*(.+)$", doctor_name_raw) if match: doctor_id = match.group(1) doctor_name = match.group(2).strip() return doctor_id, doctor_name - + # No ID prefix found return "", doctor_name_raw @@ -64,7 +65,7 @@ class DoctorRatingAdapter: def parse_date(date_str: str) -> Optional[datetime]: """ Parse date from various formats. - + Supported formats: - "22-Dec-2024 19:12:24" (HIS format) - "22-Dec-2024" @@ -75,29 +76,29 @@ class DoctorRatingAdapter: """ if not date_str: return None - + date_str = date_str.strip() - + formats = [ - '%d-%b-%Y %H:%M:%S', - '%d-%b-%Y', - '%d-%b-%y %H:%M:%S', - '%d-%b-%y', - '%Y-%m-%d %H:%M:%S', - '%Y-%m-%d', - '%d/%m/%Y %H:%M:%S', - '%d/%m/%Y', - '%m/%d/%Y %H:%M:%S', - '%m/%d/%Y', + "%d-%b-%Y %H:%M:%S", + "%d-%b-%Y", + "%d-%b-%y %H:%M:%S", + "%d-%b-%y", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + "%d/%m/%Y %H:%M:%S", + "%d/%m/%Y", + "%m/%d/%Y %H:%M:%S", + "%m/%d/%Y", ] - + for fmt in formats: try: naive_dt = datetime.strptime(date_str, fmt) return timezone.make_aware(naive_dt) except ValueError: continue - + logger.warning(f"Could not parse date: {date_str}") return None @@ -105,15 +106,15 @@ class DoctorRatingAdapter: def parse_age(age_str: str) -> str: """ Parse age string to extract just the number. - + Examples: - "36 Years" -> "36" - "36" -> "36" """ if not age_str: return "" - - match = re.search(r'(\d+)', age_str) + + match = re.search(r"(\d+)", age_str) if match: return match.group(1) return age_str @@ -122,34 +123,34 @@ class DoctorRatingAdapter: def clean_phone(phone: str) -> str: """ Clean and normalize phone number to international format. - + Examples: - "0504884011" -> "+966504884011" - "+966504884011" -> "+966504884011" """ if not phone: return "" - - phone = phone.strip().replace(' ', '').replace('-', '') - - if phone.startswith('+'): + + phone = phone.strip().replace(" ", "").replace("-", "") + + if phone.startswith("+"): return phone - + # Saudi numbers - if phone.startswith('05'): - return '+966' + phone[1:] - elif phone.startswith('5'): - return '+966' + phone - elif phone.startswith('0'): - return '+966' + phone[1:] - + if phone.startswith("05"): + return "+966" + phone[1:] + elif phone.startswith("5"): + return "+966" + phone + elif phone.startswith("0"): + return "+966" + phone[1:] + return phone @staticmethod def find_staff_by_doctor_id(doctor_id: str, hospital: Hospital, doctor_name: str = "") -> Optional[Staff]: """ Find staff record by doctor ID or name. - + Search priority: 1. Match by employee_id (exact) 2. Match by license_number (exact) @@ -157,58 +158,175 @@ class DoctorRatingAdapter: """ if not doctor_id and not doctor_name: return None - + # Try by employee_id (exact match) if doctor_id: - staff = Staff.objects.filter( - hospital=hospital, - employee_id=doctor_id - ).first() + staff = Staff.objects.filter(hospital=hospital, employee_id=doctor_id).first() if staff: return staff - + # Try by license_number if doctor_id: - staff = Staff.objects.filter( - hospital=hospital, - license_number=doctor_id - ).first() + staff = Staff.objects.filter(hospital=hospital, license_number=doctor_id).first() if staff: return staff - + # Try by name matching if doctor_name: # Try exact match first - staff = Staff.objects.filter( - hospital=hospital, - name__iexact=doctor_name - ).first() + staff = Staff.objects.filter(hospital=hospital, name__iexact=doctor_name).first() if staff: return staff - + # Try contains match on name - staff = Staff.objects.filter( - hospital=hospital, - name__icontains=doctor_name - ).first() + staff = Staff.objects.filter(hospital=hospital, name__icontains=doctor_name).first() if staff: return staff - + # Try first_name + last_name name_parts = doctor_name.split() if len(name_parts) >= 2: first_name = name_parts[0] last_name = name_parts[-1] staff = Staff.objects.filter( - hospital=hospital, - first_name__iexact=first_name, - last_name__iexact=last_name + hospital=hospital, first_name__iexact=first_name, last_name__iexact=last_name ).first() if staff: return staff - + return None + @staticmethod + def process_his_rating_record(data: Dict, hospital: Hospital) -> Dict: + """ + Process a single doctor rating record from HIS API format. + + HIS Format: + { + "DoctorID": "11510", + "EmpNo": "17046", + "DoctorName": "AAMIR USMAN BAIG", + "DoctorDepartment": "ORTHOPAEDIC", + "HospitalName": "SUWAIDI", + "HospitalID": "2", + "Rating": "5.00", + "RatingDate": "30-Dec-2025 14:06" + } + + Args: + data: Dictionary containing HIS rating data + hospital: Hospital instance + + Returns: + Dict with 'success', 'rating_id', 'message', 'staff_matched', 'is_duplicate' + """ + result = { + "success": False, + "rating_id": None, + "message": "", + "staff_matched": False, + "staff_id": None, + "is_duplicate": False, + } + + try: + with transaction.atomic(): + # Extract doctor info + doctor_id = data.get("DoctorID", "").strip() + emp_no = data.get("EmpNo", "").strip() + doctor_name = data.get("DoctorName", "").strip() + department_name = data.get("DoctorDepartment", "").strip() + + # Find staff by DoctorID or EmpNo + staff = None + if doctor_id: + staff = Staff.objects.filter(hospital=hospital, employee_id=doctor_id).first() + + if not staff and emp_no: + staff = Staff.objects.filter(hospital=hospital, employee_id=emp_no).first() + + # Try name matching as fallback + if not staff and doctor_name: + staff = DoctorRatingAdapter.find_staff_by_doctor_id( + doctor_id="", hospital=hospital, doctor_name=doctor_name + ) + + # Mark as physician if matched + if staff and not staff.physician: + staff.physician = True + staff.save(update_fields=["physician"]) + + # Parse rating date + rating_date = DoctorRatingAdapter.parse_date(data.get("RatingDate", "")) + if not rating_date: + result["message"] = f"Invalid rating date: {data.get('RatingDate')}" + return result + + # Parse rating + try: + rating = int(float(data.get("Rating", 0))) + if rating < 1 or rating > 5: + result["message"] = f"Invalid rating value: {rating}" + return result + except (ValueError, TypeError): + result["message"] = f"Invalid rating format: {data.get('Rating')}" + return result + + # Check for duplicates (DoctorID + RatingDate) + doctor_ref = doctor_id or emp_no + if doctor_ref: + existing = PhysicianIndividualRating.objects.filter( + doctor_id=doctor_ref, rating_date=rating_date, hospital=hospital + ).first() + + if existing: + result["is_duplicate"] = True + result["message"] = f"Duplicate rating for doctor {doctor_ref} on {rating_date}" + return result + + # Create individual rating (no patient data for HIS ratings) + individual_rating = PhysicianIndividualRating.objects.create( + staff=staff, + hospital=hospital, + source=PhysicianIndividualRating.RatingSource.HIS_API, + source_reference=f"HIS_{data.get('DoctorID', '')}", + doctor_name_raw=doctor_name, + doctor_id=doctor_id or emp_no, + doctor_name=doctor_name, + department_name=department_name, + # Patient fields are null for HIS ratings + patient_uhid=None, + patient_name=None, + patient_gender="", + patient_age="", + patient_nationality="", + patient_phone="", + patient_type=None, + admit_date=None, + discharge_date=None, + rating=rating, + feedback="", # HIS doesn't provide feedback + rating_date=rating_date, + is_aggregated=False, + metadata={ + "emp_no": emp_no, + "hospital_id_his": data.get("HospitalID", ""), + "hospital_name_his": data.get("HospitalName", ""), + "imported_at": timezone.now().isoformat(), + }, + ) + + result["success"] = True + result["rating_id"] = str(individual_rating.id) + result["staff_matched"] = staff is not None + result["staff_id"] = str(staff.id) if staff else None + + except Exception as e: + logger.error(f"Error processing HIS doctor rating: {str(e)}", exc_info=True) + result["message"] = str(e) + + return result + @staticmethod def get_or_create_patient(uhid: str, patient_name: str, hospital: Hospital, **kwargs) -> Optional[Patient]: """ @@ -216,31 +334,31 @@ class DoctorRatingAdapter: """ if not uhid: return None - + # Split name - name_parts = patient_name.split() if patient_name else ['Unknown', ''] - first_name = name_parts[0] if name_parts else 'Unknown' - last_name = name_parts[-1] if len(name_parts) > 1 else '' - + name_parts = patient_name.split() if patient_name else ["Unknown", ""] + first_name = name_parts[0] if name_parts else "Unknown" + last_name = name_parts[-1] if len(name_parts) > 1 else "" + patient, created = Patient.objects.get_or_create( mrn=uhid, defaults={ - 'first_name': first_name, - 'last_name': last_name, - 'primary_hospital': hospital, - } + "first_name": first_name, + "last_name": last_name, + "primary_hospital": hospital, + }, ) - + # Update patient info if provided - if kwargs.get('phone'): - patient.phone = kwargs['phone'] - if kwargs.get('nationality'): - patient.nationality = kwargs['nationality'] - if kwargs.get('gender'): - patient.gender = kwargs['gender'].lower() - if kwargs.get('date_of_birth'): - patient.date_of_birth = kwargs['date_of_birth'] - + if kwargs.get("phone"): + patient.phone = kwargs["phone"] + if kwargs.get("nationality"): + patient.nationality = kwargs["nationality"] + if kwargs.get("gender"): + patient.gender = kwargs["gender"].lower() + if kwargs.get("date_of_birth"): + patient.date_of_birth = kwargs["date_of_birth"] + patient.save() return patient @@ -249,72 +367,64 @@ class DoctorRatingAdapter: data: Dict, hospital: Hospital, source: str = PhysicianIndividualRating.RatingSource.HIS_API, - source_reference: str = "" + source_reference: str = "", ) -> Dict: """ Process a single doctor rating record. - + Args: data: Dictionary containing rating data hospital: Hospital instance source: Source of the rating (his_api, csv_import, manual) source_reference: Reference ID from source system - + Returns: Dict with 'success', 'rating_id', 'message', 'staff_matched' """ - result = { - 'success': False, - 'rating_id': None, - 'message': '', - 'staff_matched': False, - 'staff_id': None - } - + result = {"success": False, "rating_id": None, "message": "", "staff_matched": False, "staff_id": None} + try: with transaction.atomic(): # Extract and parse doctor info - doctor_name_raw = data.get('doctor_name', '').strip() + doctor_name_raw = data.get("doctor_name", "").strip() doctor_id, doctor_name = DoctorRatingAdapter.parse_doctor_name(doctor_name_raw) - + # Find staff - staff = DoctorRatingAdapter.find_staff_by_doctor_id( - doctor_id, hospital, doctor_name - ) - + staff = DoctorRatingAdapter.find_staff_by_doctor_id(doctor_id, hospital, doctor_name) + # If staff found, mark as physician if staff and not staff.physician: staff.physician = True - staff.save(update_fields=['physician']) - + staff.save(update_fields=["physician"]) + # Extract patient info - uhid = data.get('uhid', '').strip() - patient_name = data.get('patient_name', '').strip() - + uhid = data.get("uhid", "").strip() + patient_name = data.get("patient_name", "").strip() + # Parse dates - admit_date = DoctorRatingAdapter.parse_date(data.get('admit_date', '')) - discharge_date = DoctorRatingAdapter.parse_date(data.get('discharge_date', '')) - rating_date = DoctorRatingAdapter.parse_date(data.get('rating_date', '')) - + admit_date = DoctorRatingAdapter.parse_date(data.get("admit_date", "")) + discharge_date = DoctorRatingAdapter.parse_date(data.get("discharge_date", "")) + rating_date = DoctorRatingAdapter.parse_date(data.get("rating_date", "")) + if not rating_date and admit_date: rating_date = admit_date - + if not rating_date: rating_date = timezone.now() - + # Clean phone - phone = DoctorRatingAdapter.clean_phone(data.get('mobile_no', '')) - + phone = DoctorRatingAdapter.clean_phone(data.get("mobile_no", "")) + # Parse rating try: - rating = int(float(data.get('rating', 0))) + rating = int(float(data.get("rating", 0))) if rating < 1 or rating > 5: - result['message'] = f"Invalid rating value: {rating}" + result["message"] = f"Invalid rating value: {rating}" return result except (ValueError, TypeError): - result['message'] = f"Invalid rating format: {data.get('rating')}" + result["message"] = f"Invalid rating format: {data.get('rating')}" return result - + # Get or create patient patient = None if uhid: @@ -323,23 +433,23 @@ class DoctorRatingAdapter: patient_name=patient_name, hospital=hospital, phone=phone, - nationality=data.get('nationality', ''), - gender=data.get('gender', ''), + nationality=data.get("nationality", ""), + gender=data.get("gender", ""), ) - + # Determine patient type - patient_type_raw = data.get('patient_type', '').upper() + patient_type_raw = data.get("patient_type", "").upper() patient_type_map = { - 'IP': PhysicianIndividualRating.PatientType.INPATIENT, - 'OP': PhysicianIndividualRating.PatientType.OUTPATIENT, - 'OPD': PhysicianIndividualRating.PatientType.OUTPATIENT, - 'ER': PhysicianIndividualRating.PatientType.EMERGENCY, - 'EMS': PhysicianIndividualRating.PatientType.EMERGENCY, - 'DC': PhysicianIndividualRating.PatientType.DAYCASE, - 'DAYCASE': PhysicianIndividualRating.PatientType.DAYCASE, + "IP": PhysicianIndividualRating.PatientType.INPATIENT, + "OP": PhysicianIndividualRating.PatientType.OUTPATIENT, + "OPD": PhysicianIndividualRating.PatientType.OUTPATIENT, + "ER": PhysicianIndividualRating.PatientType.EMERGENCY, + "EMS": PhysicianIndividualRating.PatientType.EMERGENCY, + "DC": PhysicianIndividualRating.PatientType.DAYCASE, + "DAYCASE": PhysicianIndividualRating.PatientType.DAYCASE, } - patient_type = patient_type_map.get(patient_type_raw, '') - + patient_type = patient_type_map.get(patient_type_raw, "") + # Create individual rating individual_rating = PhysicianIndividualRating.objects.create( staff=staff, @@ -349,199 +459,165 @@ class DoctorRatingAdapter: doctor_name_raw=doctor_name_raw, doctor_id=doctor_id, doctor_name=doctor_name, - department_name=data.get('department', ''), + department_name=data.get("department", ""), patient_uhid=uhid, patient_name=patient_name, - patient_gender=data.get('gender', ''), - patient_age=DoctorRatingAdapter.parse_age(data.get('age', '')), - patient_nationality=data.get('nationality', ''), + patient_gender=data.get("gender", ""), + patient_age=DoctorRatingAdapter.parse_age(data.get("age", "")), + patient_nationality=data.get("nationality", ""), patient_phone=phone, patient_type=patient_type, admit_date=admit_date, discharge_date=discharge_date, rating=rating, - feedback=data.get('feedback', ''), + feedback=data.get("feedback", ""), rating_date=rating_date, is_aggregated=False, metadata={ - 'patient_type_raw': data.get('patient_type', ''), - 'imported_at': timezone.now().isoformat(), - } + "patient_type_raw": data.get("patient_type", ""), + "imported_at": timezone.now().isoformat(), + }, ) - - result['success'] = True - result['rating_id'] = str(individual_rating.id) - result['staff_matched'] = staff is not None - result['staff_id'] = str(staff.id) if staff else None - + + result["success"] = True + result["rating_id"] = str(individual_rating.id) + result["staff_matched"] = staff is not None + result["staff_id"] = str(staff.id) if staff else None + except Exception as e: logger.error(f"Error processing doctor rating: {str(e)}", exc_info=True) - result['message'] = str(e) - + result["message"] = str(e) + return result @staticmethod - def process_bulk_ratings( - records: List[Dict], - hospital: Hospital, - job: DoctorRatingImportJob - ) -> Dict: + def process_bulk_ratings(records: List[Dict], hospital: Hospital, job: DoctorRatingImportJob) -> Dict: """ Process multiple doctor rating records in bulk. - + Args: records: List of rating data dictionaries hospital: Hospital instance job: DoctorRatingImportJob instance for tracking - + Returns: Dict with summary statistics """ - results = { - 'total': len(records), - 'success': 0, - 'failed': 0, - 'skipped': 0, - 'staff_matched': 0, - 'errors': [] - } - + results = {"total": len(records), "success": 0, "failed": 0, "skipped": 0, "staff_matched": 0, "errors": []} + job.status = DoctorRatingImportJob.JobStatus.PROCESSING job.started_at = timezone.now() job.save() - + for idx, record in enumerate(records, 1): try: - result = DoctorRatingAdapter.process_single_rating( - data=record, - hospital=hospital, - source=job.source - ) - - if result['success']: - results['success'] += 1 - if result['staff_matched']: - results['staff_matched'] += 1 + result = DoctorRatingAdapter.process_single_rating(data=record, hospital=hospital, source=job.source) + + if result["success"]: + results["success"] += 1 + if result["staff_matched"]: + results["staff_matched"] += 1 else: - results['failed'] += 1 - results['errors'].append({ - 'row': idx, - 'message': result['message'], - 'data': record - }) - + results["failed"] += 1 + results["errors"].append({"row": idx, "message": result["message"], "data": record}) + # Update progress every 10 records if idx % 10 == 0: job.processed_count = idx - job.success_count = results['success'] - job.failed_count = results['failed'] - job.skipped_count = results['skipped'] + job.success_count = results["success"] + job.failed_count = results["failed"] + job.skipped_count = results["skipped"] job.save() - + except Exception as e: - results['failed'] += 1 - results['errors'].append({ - 'row': idx, - 'message': str(e), - 'data': record - }) + results["failed"] += 1 + results["errors"].append({"row": idx, "message": str(e), "data": record}) logger.error(f"Error processing record {idx}: {str(e)}", exc_info=True) - + # Final update - job.processed_count = results['total'] - job.success_count = results['success'] - job.failed_count = results['failed'] - job.skipped_count = results['skipped'] + job.processed_count = results["total"] + job.success_count = results["success"] + job.failed_count = results["failed"] + job.skipped_count = results["skipped"] job.results = results job.completed_at = timezone.now() - + # Determine final status - if results['failed'] == 0: + if results["failed"] == 0: job.status = DoctorRatingImportJob.JobStatus.COMPLETED - elif results['success'] == 0: + elif results["success"] == 0: job.status = DoctorRatingImportJob.JobStatus.FAILED else: job.status = DoctorRatingImportJob.JobStatus.PARTIAL - + job.save() - + return results @staticmethod def aggregate_monthly_ratings(year: int, month: int, hospital: Hospital = None) -> Dict: """ Aggregate individual ratings into monthly summaries. - + This should be called after importing ratings to update the monthly aggregates. - + Args: year: Year to aggregate month: Month to aggregate (1-12) hospital: Optional hospital filter (if None, aggregates all) - + Returns: Dict with summary of aggregations """ from django.db.models import Avg, Count, Q - - results = { - 'aggregated': 0, - 'errors': [] - } - + + results = {"aggregated": 0, "errors": []} + # Get unaggregated ratings for the period queryset = PhysicianIndividualRating.objects.filter( - rating_date__year=year, - rating_date__month=month, - is_aggregated=False + rating_date__year=year, rating_date__month=month, is_aggregated=False ) - + if hospital: queryset = queryset.filter(hospital=hospital) - + # Group by staff - staff_ratings = queryset.values('staff').annotate( - avg_rating=Avg('rating'), - total_count=Count('id'), - positive_count=Count('id', filter=Q(rating__gte=4)), - neutral_count=Count('id', filter=Q(rating__gte=3, rating__lt=4)), - negative_count=Count('id', filter=Q(rating__lt=3)) + staff_ratings = queryset.values("staff").annotate( + avg_rating=Avg("rating"), + total_count=Count("id"), + positive_count=Count("id", filter=Q(rating__gte=4)), + neutral_count=Count("id", filter=Q(rating__gte=3, rating__lt=4)), + negative_count=Count("id", filter=Q(rating__lt=3)), ) - + for group in staff_ratings: - staff_id = group['staff'] + staff_id = group["staff"] if not staff_id: continue - + try: staff = Staff.objects.get(id=staff_id) - + # Update or create monthly rating monthly_rating, created = PhysicianMonthlyRating.objects.update_or_create( staff=staff, year=year, month=month, defaults={ - 'average_rating': round(group['avg_rating'], 2), - 'total_surveys': group['total_count'], - 'positive_count': group['positive_count'], - 'neutral_count': group['neutral_count'], - 'negative_count': group['negative_count'], - } + "average_rating": round(group["avg_rating"], 2), + "total_surveys": group["total_count"], + "positive_count": group["positive_count"], + "neutral_count": group["neutral_count"], + "negative_count": group["negative_count"], + }, ) - + # Mark individual ratings as aggregated - queryset.filter(staff=staff).update( - is_aggregated=True, - aggregated_at=timezone.now() - ) - - results['aggregated'] += 1 - + queryset.filter(staff=staff).update(is_aggregated=True, aggregated_at=timezone.now()) + + results["aggregated"] += 1 + except Exception as e: - results['errors'].append({ - 'staff_id': str(staff_id), - 'error': str(e) - }) - + results["errors"].append({"staff_id": str(staff_id), "error": str(e)}) + return results diff --git a/apps/physicians/management/commands/import_his_doctor_ratings.py b/apps/physicians/management/commands/import_his_doctor_ratings.py new file mode 100644 index 0000000..1a76280 --- /dev/null +++ b/apps/physicians/management/commands/import_his_doctor_ratings.py @@ -0,0 +1,330 @@ +""" +Management command to import doctor ratings from HIS API. + +This command fetches doctor ratings from the HIS FetchDoctorRatingMAPI1 endpoint +and imports them into the system. It supports importing for specific months, +multiple months, or full historical data. + +Usage: + # Import previous month (default) + python manage.py import_his_doctor_ratings + + # Import specific month + python manage.py import_his_doctor_ratings --month 2026-01 + + # Import last 6 months + python manage.py import_his_doctor_ratings --months-back 6 + + # Import last 12 months + python manage.py import_his_doctor_ratings --months-back 12 + + # Import all historical data + python manage.py import_his_doctor_ratings --full-history + + # Dry run (show what would be imported) + python manage.py import_his_doctor_ratings --month 2026-01 --dry-run +""" + +import logging +from calendar import monthrange +from datetime import datetime, timedelta +from typing import List, Optional + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from apps.integrations.services.his_client import HISClient +from apps.organizations.models import Hospital +from apps.physicians.adapter import DoctorRatingAdapter +from apps.physicians.models import ( + DoctorRatingImportJob, + PhysicianIndividualRating, +) + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Import doctor ratings from HIS API" + + def add_arguments(self, parser): + parser.add_argument("--month", type=str, help="Specific month to import (format: YYYY-MM, e.g., 2026-01)") + parser.add_argument("--months-back", type=int, help="Import ratings for the last N months (e.g., 6 or 12)") + parser.add_argument("--full-history", action="store_true", help="Import all historical ratings from HIS") + parser.add_argument( + "--dry-run", action="store_true", help="Show what would be imported without actually importing" + ) + parser.add_argument("--force", action="store_true", help="Force import even if ratings already exist") + + def handle(self, *args, **options): + self.dry_run = options["dry_run"] + self.force = options["force"] + + # Determine date ranges to import + date_ranges = self._get_date_ranges(options) + + if not date_ranges: + raise CommandError("No date range specified. Use --month, --months-back, or --full-history") + + # Initialize HIS client + client = HISClient() + + # Track overall stats + total_stats = { + "total_months": len(date_ranges), + "total_ratings": 0, + "success": 0, + "failed": 0, + "duplicates": 0, + "staff_matched": 0, + } + + for from_date, to_date in date_ranges: + month_label = from_date.strftime("%Y-%m") + self.stdout.write(f"\n{'=' * 60}") + self.stdout.write(f"Processing month: {month_label}") + self.stdout.write(f"Date range: {from_date} to {to_date}") + self.stdout.write(f"{'=' * 60}\n") + + # Fetch ratings from HIS + self.stdout.write("Fetching ratings from HIS...") + his_data = client.fetch_doctor_ratings(from_date, to_date) + + if not his_data: + self.stdout.write(self.style.ERROR("Failed to fetch data from HIS")) + continue + + if his_data.get("Code") != 200: + error_msg = his_data.get("Message", "Unknown error") + self.stdout.write(self.style.ERROR(f"HIS API error: {error_msg}")) + continue + + ratings_list = his_data.get("FetchDoctorRatingMAPI1List", []) + + if not ratings_list: + self.stdout.write(self.style.WARNING("No ratings found for this period")) + continue + + self.stdout.write(f"Found {len(ratings_list)} ratings to process\n") + + # Process ratings + month_stats = self._process_ratings(ratings_list, from_date, to_date) + + # Update totals + total_stats["total_ratings"] += month_stats["total"] + total_stats["success"] += month_stats["success"] + total_stats["failed"] += month_stats["failed"] + total_stats["duplicates"] += month_stats["duplicates"] + total_stats["staff_matched"] += month_stats["staff_matched"] + + # Print month summary + self._print_month_summary(month_label, month_stats) + + # Print final summary + self._print_final_summary(total_stats) + + def _get_date_ranges(self, options) -> List[tuple]: + """Determine date ranges to import based on options.""" + ranges = [] + + now = timezone.now() + + if options["month"]: + # Specific month + try: + year, month = map(int, options["month"].split("-")) + from_date = datetime(year, month, 1) + last_day = monthrange(year, month)[1] + to_date = datetime(year, month, last_day, 23, 59, 59) + ranges.append((from_date, to_date)) + except ValueError: + raise CommandError("Invalid month format. Use YYYY-MM (e.g., 2026-01)") + + elif options["months_back"]: + # Last N months + months_back = options["months_back"] + for i in range(months_back): + # Calculate month + target_month = now.month - i + target_year = now.year + + while target_month <= 0: + target_month += 12 + target_year -= 1 + + from_date = datetime(target_year, target_month, 1) + last_day = monthrange(target_year, target_month)[1] + to_date = datetime(target_year, target_month, last_day, 23, 59, 59) + ranges.append((from_date, to_date)) + + elif options["full_history"]: + # Full history - go back 5 years + self.stdout.write( + self.style.WARNING("Full history import requested. This will import data for the last 5 years.") + ) + for i in range(60): # 5 years = 60 months + target_month = now.month - i + target_year = now.year + + while target_month <= 0: + target_month += 12 + target_year -= 1 + + from_date = datetime(target_year, target_month, 1) + last_day = monthrange(target_year, target_month)[1] + to_date = datetime(target_year, target_month, last_day, 23, 59, 59) + ranges.append((from_date, to_date)) + + else: + # Default: previous month + if now.month == 1: + prev_month = 12 + prev_year = now.year - 1 + else: + prev_month = now.month - 1 + prev_year = now.year + + from_date = datetime(prev_year, prev_month, 1) + last_day = monthrange(prev_year, prev_month)[1] + to_date = datetime(prev_year, prev_month, last_day, 23, 59, 59) + ranges.append((from_date, to_date)) + + return ranges + + def _process_ratings(self, ratings_list: List[dict], from_date: datetime, to_date: datetime) -> dict: + """Process list of ratings from HIS.""" + stats = { + "total": len(ratings_list), + "success": 0, + "failed": 0, + "duplicates": 0, + "staff_matched": 0, + "errors": [], + } + + # Create import job + if not self.dry_run: + # Find first hospital to create job + first_hospital = Hospital.objects.first() + if first_hospital: + job = DoctorRatingImportJob.objects.create( + name=f"HIS Import {from_date.strftime('%Y-%m')}", + status=DoctorRatingImportJob.JobStatus.PROCESSING, + source=DoctorRatingImportJob.JobSource.HIS_API, + hospital=first_hospital, + total_records=len(ratings_list), + started_at=timezone.now(), + ) + else: + job = None + else: + job = None + + for idx, rating_data in enumerate(ratings_list, 1): + # Progress indicator + if idx % 100 == 0 or idx == len(ratings_list): + self.stdout.write(f" Processed {idx}/{len(ratings_list)}...") + + try: + # Find hospital by name + hospital_name = rating_data.get("HospitalName", "") + hospital = Hospital.objects.filter(name__iexact=hospital_name).first() + + if not hospital: + # Try partial match + hospital = Hospital.objects.filter(name__icontains=hospital_name).first() + + if not hospital: + stats["failed"] += 1 + stats["errors"].append( + {"row": idx, "error": f"Hospital not found: {hospital_name}", "data": rating_data} + ) + continue + + if self.dry_run: + # Check if would be duplicate + doctor_id = rating_data.get("DoctorID", "") + rating_date_str = rating_data.get("RatingDate", "") + rating_date = DoctorRatingAdapter.parse_date(rating_date_str) + + if doctor_id and rating_date: + existing = PhysicianIndividualRating.objects.filter( + doctor_id=doctor_id, rating_date=rating_date, hospital=hospital + ).exists() + + if existing: + stats["duplicates"] += 1 + else: + stats["success"] += 1 + else: + stats["failed"] += 1 + else: + # Process the rating + result = DoctorRatingAdapter.process_his_rating_record(rating_data, hospital) + + if result["is_duplicate"]: + stats["duplicates"] += 1 + elif result["success"]: + stats["success"] += 1 + if result["staff_matched"]: + stats["staff_matched"] += 1 + else: + stats["failed"] += 1 + stats["errors"].append( + {"row": idx, "error": result.get("message", "Unknown error"), "data": rating_data} + ) + + except Exception as e: + stats["failed"] += 1 + stats["errors"].append({"row": idx, "error": str(e), "data": rating_data}) + logger.error(f"Error processing rating {idx}: {e}", exc_info=True) + + # Update job status + if job: + job.processed_count = stats["total"] + job.success_count = stats["success"] + job.failed_count = stats["failed"] + job.completed_at = timezone.now() + + if stats["failed"] == 0: + job.status = DoctorRatingImportJob.JobStatus.COMPLETED + elif stats["success"] == 0: + job.status = DoctorRatingImportJob.JobStatus.FAILED + else: + job.status = DoctorRatingImportJob.JobStatus.PARTIAL + + job.results = { + "stats": stats, + "errors": stats["errors"][:50], # Limit errors stored + } + job.save() + + return stats + + def _print_month_summary(self, month_label: str, stats: dict): + """Print summary for a month.""" + self.stdout.write(f"\n{'-' * 60}") + self.stdout.write(f"Summary for {month_label}:") + self.stdout.write(f" Total ratings: {stats['total']}") + self.stdout.write(self.style.SUCCESS(f" Successful: {stats['success']}")) + self.stdout.write(self.style.WARNING(f" Duplicates skipped: {stats['duplicates']}")) + self.stdout.write(self.style.ERROR(f" Failed: {stats['failed']}")) + if stats["success"] > 0: + match_pct = (stats["staff_matched"] / stats["success"]) * 100 + self.stdout.write(f" Staff matched: {stats['staff_matched']} ({match_pct:.1f}%)") + self.stdout.write(f"{'-' * 60}\n") + + def _print_final_summary(self, stats: dict): + """Print final summary.""" + self.stdout.write(f"\n{'=' * 60}") + self.stdout.write("FINAL SUMMARY") + self.stdout.write(f"{'=' * 60}") + self.stdout.write(f"Total months processed: {stats['total_months']}") + self.stdout.write(f"Total ratings processed: {stats['total_ratings']}") + self.stdout.write(self.style.SUCCESS(f"Total successful: {stats['success']}")) + self.stdout.write(self.style.WARNING(f"Total duplicates: {stats['duplicates']}")) + self.stdout.write(self.style.ERROR(f"Total failed: {stats['failed']}")) + if stats["success"] > 0: + match_pct = (stats["staff_matched"] / stats["success"]) * 100 + self.stdout.write(f"Total staff matched: {stats['staff_matched']} ({match_pct:.1f}%)") + self.stdout.write(f"{'=' * 60}\n") diff --git a/apps/physicians/models.py b/apps/physicians/models.py index da1c302..cdd33d8 100644 --- a/apps/physicians/models.py +++ b/apps/physicians/models.py @@ -7,6 +7,7 @@ This module implements physician performance tracking: - Leaderboards - HIS Doctor Rating imports """ + from django.db import models from apps.core.models import TimeStampedModel, UUIDModel @@ -18,58 +19,36 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel): Calculated monthly from all surveys mentioning this physician. """ - staff = models.ForeignKey( - 'organizations.Staff', - on_delete=models.CASCADE, - related_name='monthly_ratings' - ) + + staff = models.ForeignKey("organizations.Staff", on_delete=models.CASCADE, related_name="monthly_ratings") # Time period year = models.IntegerField(db_index=True) month = models.IntegerField(db_index=True, help_text="1-12") # Ratings - average_rating = models.DecimalField( - max_digits=3, - decimal_places=2, - help_text="Average rating (1-5)" - ) - total_surveys = models.IntegerField( - help_text="Number of surveys included" - ) + average_rating = models.DecimalField(max_digits=3, decimal_places=2, help_text="Average rating (1-5)") + total_surveys = models.IntegerField(help_text="Number of surveys included") positive_count = models.IntegerField(default=0) neutral_count = models.IntegerField(default=0) negative_count = models.IntegerField(default=0) # Breakdown by journey stage - md_consult_rating = models.DecimalField( - max_digits=3, - decimal_places=2, - null=True, - blank=True - ) + md_consult_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True) # Ranking - hospital_rank = models.IntegerField( - null=True, - blank=True, - help_text="Rank within hospital" - ) - department_rank = models.IntegerField( - null=True, - blank=True, - help_text="Rank within department" - ) + hospital_rank = models.IntegerField(null=True, blank=True, help_text="Rank within hospital") + department_rank = models.IntegerField(null=True, blank=True, help_text="Rank within department") # Metadata metadata = models.JSONField(default=dict, blank=True) class Meta: - ordering = ['-year', '-month', '-average_rating'] - unique_together = [['staff', 'year', 'month']] + ordering = ["-year", "-month", "-average_rating"] + unique_together = [["staff", "year", "month"]] indexes = [ - models.Index(fields=['staff', '-year', '-month']), - models.Index(fields=['year', 'month', '-average_rating']), + models.Index(fields=["staff", "-year", "-month"]), + models.Index(fields=["year", "month", "-average_rating"]), ] def __str__(self): @@ -79,120 +58,84 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel): class PhysicianIndividualRating(UUIDModel, TimeStampedModel): """ Individual physician rating from HIS or manual import. - + Stores each individual patient rating before aggregation. Source can be HIS integration, CSV import, or manual entry. """ + class RatingSource(models.TextChoices): - HIS_API = 'his_api', 'HIS API' - CSV_IMPORT = 'csv_import', 'CSV Import' - MANUAL = 'manual', 'Manual Entry' + HIS_API = "his_api", "HIS API" + CSV_IMPORT = "csv_import", "CSV Import" + MANUAL = "manual", "Manual Entry" class PatientType(models.TextChoices): - INPATIENT = 'IP', 'Inpatient' - OUTPATIENT = 'OP', 'Outpatient' - EMERGENCY = 'ER', 'Emergency' - DAYCASE = 'DC', 'Day Case' + INPATIENT = "IP", "Inpatient" + OUTPATIENT = "OP", "Outpatient" + EMERGENCY = "ER", "Emergency" + DAYCASE = "DC", "Day Case" # Links staff = models.ForeignKey( - 'organizations.Staff', + "organizations.Staff", on_delete=models.CASCADE, - related_name='individual_ratings', + related_name="individual_ratings", null=True, blank=True, - help_text="Linked staff record (if matched)" - ) - hospital = models.ForeignKey( - 'organizations.Hospital', - on_delete=models.CASCADE, - related_name='physician_ratings' + help_text="Linked staff record (if matched)", ) + hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="physician_ratings") # Source tracking - source = models.CharField( - max_length=20, - choices=RatingSource.choices, - default=RatingSource.MANUAL - ) + source = models.CharField(max_length=20, choices=RatingSource.choices, default=RatingSource.MANUAL) source_reference = models.CharField( - max_length=100, - blank=True, - help_text="Reference ID from source system (e.g., HIS record ID)" + max_length=100, blank=True, help_text="Reference ID from source system (e.g., HIS record ID)" ) # Doctor information (as received from source) - doctor_name_raw = models.CharField( - max_length=300, - help_text="Doctor name as received (may include ID prefix)" - ) + doctor_name_raw = models.CharField(max_length=300, help_text="Doctor name as received (may include ID prefix)") doctor_id = models.CharField( - max_length=50, - blank=True, - db_index=True, - help_text="Doctor ID extracted from source (e.g., '10738')" - ) - doctor_name = models.CharField( - max_length=200, - blank=True, - help_text="Clean doctor name without ID" - ) - department_name = models.CharField( - max_length=200, - blank=True, - help_text="Department name from source" + max_length=50, blank=True, db_index=True, help_text="Doctor ID extracted from source (e.g., '10738')" ) + doctor_name = models.CharField(max_length=200, blank=True, help_text="Clean doctor name without ID") + department_name = models.CharField(max_length=200, blank=True, help_text="Department name from source") - # Patient information + # Patient information (optional - HIS ratings don't include patient data) patient_uhid = models.CharField( - max_length=100, - db_index=True, - help_text="Patient UHID/MRN" - ) - patient_name = models.CharField(max_length=300) - patient_gender = models.CharField(max_length=20, blank=True) - patient_age = models.CharField(max_length=50, blank=True) - patient_nationality = models.CharField(max_length=100, blank=True) - patient_phone = models.CharField(max_length=30, blank=True) - patient_type = models.CharField( - max_length=10, - choices=PatientType.choices, - blank=True + max_length=100, blank=True, null=True, db_index=True, help_text="Patient UHID/MRN (optional for HIS ratings)" ) + patient_name = models.CharField(max_length=300, blank=True, null=True) + patient_gender = models.CharField(max_length=20, blank=True, default="") + patient_age = models.CharField(max_length=50, blank=True, default="") + patient_nationality = models.CharField(max_length=100, blank=True, default="") + patient_phone = models.CharField(max_length=30, blank=True, default="") + patient_type = models.CharField(max_length=10, choices=PatientType.choices, blank=True, null=True) # Visit dates admit_date = models.DateTimeField(null=True, blank=True) discharge_date = models.DateTimeField(null=True, blank=True) # Rating data - rating = models.IntegerField( - help_text="Rating from 1-5" - ) + rating = models.IntegerField(help_text="Rating from 1-5") feedback = models.TextField(blank=True) rating_date = models.DateTimeField() # Aggregation tracking is_aggregated = models.BooleanField( - default=False, - help_text="Whether this rating has been included in monthly aggregation" + default=False, help_text="Whether this rating has been included in monthly aggregation" ) aggregated_at = models.DateTimeField(null=True, blank=True) # Metadata - metadata = models.JSONField( - default=dict, - blank=True, - help_text="Additional data from source" - ) + metadata = models.JSONField(default=dict, blank=True, help_text="Additional data from source") class Meta: - ordering = ['-rating_date', '-created_at'] + ordering = ["-rating_date", "-created_at"] indexes = [ - models.Index(fields=['hospital', '-rating_date']), - models.Index(fields=['staff', '-rating_date']), - models.Index(fields=['doctor_id', '-rating_date']), - models.Index(fields=['is_aggregated', 'rating_date']), - models.Index(fields=['patient_uhid', '-rating_date']), + models.Index(fields=["hospital", "-rating_date"]), + models.Index(fields=["staff", "-rating_date"]), + models.Index(fields=["doctor_id", "-rating_date"]), + models.Index(fields=["is_aggregated", "rating_date"]), + models.Index(fields=["patient_uhid", "-rating_date"]), ] def __str__(self): @@ -203,41 +146,28 @@ class DoctorRatingImportJob(UUIDModel, TimeStampedModel): """ Tracks bulk doctor rating import jobs (CSV or API batch). """ + class JobStatus(models.TextChoices): - PENDING = 'pending', 'Pending' - PROCESSING = 'processing', 'Processing' - COMPLETED = 'completed', 'Completed' - FAILED = 'failed', 'Failed' - PARTIAL = 'partial', 'Partial Success' + PENDING = "pending", "Pending" + PROCESSING = "processing", "Processing" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + PARTIAL = "partial", "Partial Success" class JobSource(models.TextChoices): - HIS_API = 'his_api', 'HIS API' - CSV_UPLOAD = 'csv_upload', 'CSV Upload' + HIS_API = "his_api", "HIS API" + CSV_UPLOAD = "csv_upload", "CSV Upload" # Job info name = models.CharField(max_length=200) - status = models.CharField( - max_length=20, - choices=JobStatus.choices, - default=JobStatus.PENDING - ) - source = models.CharField( - max_length=20, - choices=JobSource.choices - ) + status = models.CharField(max_length=20, choices=JobStatus.choices, default=JobStatus.PENDING) + source = models.CharField(max_length=20, choices=JobSource.choices) # User & Organization created_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - related_name='doctor_rating_jobs' - ) - hospital = models.ForeignKey( - 'organizations.Hospital', - on_delete=models.CASCADE, - related_name='doctor_rating_jobs' + "accounts.User", on_delete=models.SET_NULL, null=True, related_name="doctor_rating_jobs" ) + hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="doctor_rating_jobs") # Progress tracking total_records = models.IntegerField(default=0) @@ -251,22 +181,14 @@ class DoctorRatingImportJob(UUIDModel, TimeStampedModel): completed_at = models.DateTimeField(null=True, blank=True) # Results - results = models.JSONField( - default=dict, - blank=True, - help_text="Processing results and errors" - ) + results = models.JSONField(default=dict, blank=True, help_text="Processing results and errors") error_message = models.TextField(blank=True) # Raw data storage (for CSV uploads) - raw_data = models.JSONField( - default=list, - blank=True, - help_text="Stored raw data for processing" - ) + raw_data = models.JSONField(default=list, blank=True, help_text="Stored raw data for processing") class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] def __str__(self): return f"{self.name} - {self.status}" diff --git a/apps/physicians/tasks.py b/apps/physicians/tasks.py index 433a439..867ef00 100644 --- a/apps/physicians/tasks.py +++ b/apps/physicians/tasks.py @@ -6,6 +6,7 @@ Background tasks for: - Monthly aggregation of ratings - Ranking updates """ + import logging from celery import shared_task @@ -23,55 +24,52 @@ logger = logging.getLogger(__name__) def process_doctor_rating_job(self, job_id: str): """ Process a doctor rating import job in the background. - + This task is called when a bulk import is queued (from API or CSV upload). """ try: job = DoctorRatingImportJob.objects.get(id=job_id) except DoctorRatingImportJob.DoesNotExist: logger.error(f"Doctor rating import job {job_id} not found") - return {'error': 'Job not found'} - + return {"error": "Job not found"} + try: # Update job status job.status = DoctorRatingImportJob.JobStatus.PROCESSING job.started_at = timezone.now() job.save() - + logger.info(f"Starting doctor rating import job {job_id}: {job.total_records} records") - + # Get raw data records = job.raw_data hospital = job.hospital - + # Process through adapter - results = DoctorRatingAdapter.process_bulk_ratings( - records=records, - hospital=hospital, - job=job + results = DoctorRatingAdapter.process_bulk_ratings(records=records, hospital=hospital, job=job) + + logger.info( + f"Completed doctor rating import job {job_id}: {results['success']} success, {results['failed']} failed" ) - - logger.info(f"Completed doctor rating import job {job_id}: " - f"{results['success']} success, {results['failed']} failed") - + return { - 'job_id': job_id, - 'total': results['total'], - 'success': results['success'], - 'failed': results['failed'], - 'skipped': results['skipped'], - 'staff_matched': results['staff_matched'] + "job_id": job_id, + "total": results["total"], + "success": results["success"], + "failed": results["failed"], + "skipped": results["skipped"], + "staff_matched": results["staff_matched"], } - + except Exception as exc: logger.error(f"Error processing doctor rating job {job_id}: {str(exc)}", exc_info=True) - + # Update job status job.status = DoctorRatingImportJob.JobStatus.FAILED job.error_message = str(exc) job.completed_at = timezone.now() job.save() - + # Retry raise self.retry(exc=exc) @@ -80,7 +78,7 @@ def process_doctor_rating_job(self, job_id: str): def aggregate_monthly_ratings_task(self, year: int, month: int, hospital_id: str = None): """ Aggregate individual ratings into monthly summaries. - + Args: year: Year to aggregate month: Month to aggregate (1-12) @@ -88,41 +86,38 @@ def aggregate_monthly_ratings_task(self, year: int, month: int, hospital_id: str """ try: logger.info(f"Starting monthly aggregation for {year}-{month:02d}") - + hospital = None if hospital_id: try: hospital = Hospital.objects.get(id=hospital_id) except Hospital.DoesNotExist: logger.error(f"Hospital {hospital_id} not found") - return {'error': 'Hospital not found'} - + return {"error": "Hospital not found"} + # Run aggregation - results = DoctorRatingAdapter.aggregate_monthly_ratings( - year=year, - month=month, - hospital=hospital + results = DoctorRatingAdapter.aggregate_monthly_ratings(year=year, month=month, hospital=hospital) + + logger.info( + f"Completed monthly aggregation for {year}-{month:02d}: {results['aggregated']} physicians aggregated" ) - - logger.info(f"Completed monthly aggregation for {year}-{month:02d}: " - f"{results['aggregated']} physicians aggregated") - + # Calculate rankings after aggregation if hospital: update_hospital_rankings.delay(year, month, hospital_id) else: # Update rankings for all hospitals - for h in Hospital.objects.filter(status='active'): + for h in Hospital.objects.filter(status="active"): update_hospital_rankings.delay(year, month, str(h.id)) - + return { - 'year': year, - 'month': month, - 'hospital_id': hospital_id, - 'aggregated': results['aggregated'], - 'errors': len(results['errors']) + "year": year, + "month": month, + "hospital_id": hospital_id, + "aggregated": results["aggregated"], + "errors": len(results["errors"]), } - + except Exception as exc: logger.error(f"Error aggregating monthly ratings: {str(exc)}", exc_info=True) raise self.retry(exc=exc) @@ -132,51 +127,49 @@ def aggregate_monthly_ratings_task(self, year: int, month: int, hospital_id: str def update_hospital_rankings(self, year: int, month: int, hospital_id: str): """ Update hospital and department rankings for physicians. - + This should be called after monthly aggregation is complete. """ try: from django.db.models import Window, F from django.db.models.functions import RowNumber - + hospital = Hospital.objects.get(id=hospital_id) - + logger.info(f"Updating rankings for {hospital.name} - {year}-{month:02d}") - + # Get all ratings for this hospital and period ratings = PhysicianMonthlyRating.objects.filter( - staff__hospital=hospital, - year=year, - month=month - ).select_related('staff', 'staff__department') - + staff__hospital=hospital, year=year, month=month + ).select_related("staff", "staff__department") + # Update hospital rankings (order by average_rating desc) - hospital_rankings = list(ratings.order_by('-average_rating')) + hospital_rankings = list(ratings.order_by("-average_rating")) for rank, rating in enumerate(hospital_rankings, start=1): rating.hospital_rank = rank - rating.save(update_fields=['hospital_rank']) - + rating.save(update_fields=["hospital_rank"]) + # Update department rankings from apps.organizations.models import Department + departments = Department.objects.filter(hospital=hospital) - + for dept in departments: - dept_ratings = ratings.filter(staff__department=dept).order_by('-average_rating') + dept_ratings = ratings.filter(staff__department=dept).order_by("-average_rating") for rank, rating in enumerate(dept_ratings, start=1): rating.department_rank = rank - rating.save(update_fields=['department_rank']) - - logger.info(f"Updated rankings for {hospital.name}: " - f"{len(hospital_rankings)} physicians ranked") - + rating.save(update_fields=["department_rank"]) + + logger.info(f"Updated rankings for {hospital.name}: {len(hospital_rankings)} physicians ranked") + return { - 'hospital_id': hospital_id, - 'hospital_name': hospital.name, - 'year': year, - 'month': month, - 'total_ranked': len(hospital_rankings) + "hospital_id": hospital_id, + "hospital_name": hospital.name, + "year": year, + "month": month, + "total_ranked": len(hospital_rankings), } - + except Exception as exc: logger.error(f"Error updating rankings: {str(exc)}", exc_info=True) raise self.retry(exc=exc) @@ -186,76 +179,234 @@ def update_hospital_rankings(self, year: int, month: int, hospital_id: str): def auto_aggregate_daily(): """ Daily task to automatically aggregate unaggregated ratings. - + This task should be scheduled to run daily to keep monthly ratings up-to-date. """ try: logger.info("Starting daily auto-aggregation of doctor ratings") - + # Find months with unaggregated ratings - unaggregated = PhysicianIndividualRating.objects.filter( - is_aggregated=False - ).values('rating_date__year', 'rating_date__month').distinct() - + unaggregated = ( + PhysicianIndividualRating.objects.filter(is_aggregated=False) + .values("rating_date__year", "rating_date__month") + .distinct() + ) + aggregated_count = 0 for item in unaggregated: - year = item['rating_date__year'] - month = item['rating_date__month'] - + year = item["rating_date__year"] + month = item["rating_date__month"] + # Aggregate for each hospital separately - hospitals_with_ratings = PhysicianIndividualRating.objects.filter( - is_aggregated=False, - rating_date__year=year, - rating_date__month=month - ).values_list('hospital', flat=True).distinct() - - for hospital_id in hospitals_with_ratings: - results = DoctorRatingAdapter.aggregate_monthly_ratings( - year=year, - month=month, - hospital_id=hospital_id + hospitals_with_ratings = ( + PhysicianIndividualRating.objects.filter( + is_aggregated=False, rating_date__year=year, rating_date__month=month ) - aggregated_count += results['aggregated'] - + .values_list("hospital", flat=True) + .distinct() + ) + + for hospital_id in hospitals_with_ratings: + results = DoctorRatingAdapter.aggregate_monthly_ratings(year=year, month=month, hospital_id=hospital_id) + aggregated_count += results["aggregated"] + logger.info(f"Daily auto-aggregation complete: {aggregated_count} physicians updated") - - return { - 'aggregated_count': aggregated_count - } - + + return {"aggregated_count": aggregated_count} + except Exception as e: logger.error(f"Error in daily auto-aggregation: {str(e)}", exc_info=True) - return {'error': str(e)} + return {"error": str(e)} @shared_task def cleanup_old_import_jobs(days: int = 30): """ Clean up old completed import jobs and their raw data. - + Args: days: Delete jobs older than this many days """ from datetime import timedelta - + cutoff_date = timezone.now() - timedelta(days=days) - + old_jobs = DoctorRatingImportJob.objects.filter( created_at__lt=cutoff_date, - status__in=[ - DoctorRatingImportJob.JobStatus.COMPLETED, - DoctorRatingImportJob.JobStatus.FAILED - ] + status__in=[DoctorRatingImportJob.JobStatus.COMPLETED, DoctorRatingImportJob.JobStatus.FAILED], ) - + count = old_jobs.count() - + # Clear raw data first to save space for job in old_jobs: if job.raw_data: job.raw_data = [] - job.save(update_fields=['raw_data']) - + job.save(update_fields=["raw_data"]) + logger.info(f"Cleaned up {count} old doctor rating import jobs") - - return {'cleaned_count': count} + + return {"cleaned_count": count} + + +@shared_task(bind=True, max_retries=3, default_retry_delay=300) +def fetch_his_doctor_ratings_monthly(self): + """ + Monthly task to fetch doctor ratings from HIS API. + + Runs on the 1st of each month to fetch the previous month's ratings. + Example: On March 1st, fetches all ratings from February 1-28/29. + + This task runs at 1:00 AM on the 1st of each month, before the + aggregation task which runs at 2:00 AM. + """ + from datetime import datetime + from calendar import monthrange + + try: + # Calculate previous month + now = timezone.now() + if now.month == 1: + target_year = now.year - 1 + target_month = 12 + else: + target_year = now.year + target_month = now.month - 1 + + month_label = f"{target_year}-{target_month:02d}" + logger.info(f"Starting monthly HIS doctor rating fetch for {month_label}") + + # Calculate date range for the month + from_date = datetime(target_year, target_month, 1) + last_day = monthrange(target_year, target_month)[1] + to_date = datetime(target_year, target_month, last_day, 23, 59, 59) + + # Initialize HIS client + from apps.integrations.services.his_client import HISClient + + client = HISClient() + + # Fetch ratings from HIS + his_data = client.fetch_doctor_ratings(from_date, to_date) + + if not his_data: + logger.error("Failed to fetch data from HIS API") + return {"success": False, "error": "Failed to fetch data from HIS API", "month": month_label} + + if his_data.get("Code") != 200: + error_msg = his_data.get("Message", "Unknown error") + logger.error(f"HIS API error: {error_msg}") + return {"success": False, "error": f"HIS API error: {error_msg}", "month": month_label} + + ratings_list = his_data.get("FetchDoctorRatingMAPI1List", []) + + if not ratings_list: + logger.info(f"No ratings found for {month_label}") + return { + "success": True, + "month": month_label, + "total_ratings": 0, + "message": "No ratings found for this period", + } + + logger.info(f"Fetched {len(ratings_list)} ratings from HIS for {month_label}") + + # Create import job for tracking + first_hospital = Hospital.objects.first() + if first_hospital: + job = DoctorRatingImportJob.objects.create( + name=f"Monthly HIS Import - {month_label}", + status=DoctorRatingImportJob.JobStatus.PROCESSING, + source=DoctorRatingImportJob.JobSource.HIS_API, + hospital=first_hospital, + total_records=len(ratings_list), + started_at=timezone.now(), + ) + else: + job = None + logger.warning("No hospitals found, creating ratings without import job") + + # Process ratings + stats = { + "total": len(ratings_list), + "success": 0, + "failed": 0, + "duplicates": 0, + "staff_matched": 0, + } + + for idx, rating_data in enumerate(ratings_list, 1): + try: + # Find hospital by name + hospital_name = rating_data.get("HospitalName", "") + hospital = Hospital.objects.filter(name__iexact=hospital_name).first() + + if not hospital: + hospital = Hospital.objects.filter(name__icontains=hospital_name).first() + + if not hospital: + stats["failed"] += 1 + logger.warning(f"Hospital not found: {hospital_name}") + continue + + # Process the rating + result = DoctorRatingAdapter.process_his_rating_record(rating_data, hospital) + + if result["is_duplicate"]: + stats["duplicates"] += 1 + elif result["success"]: + stats["success"] += 1 + if result["staff_matched"]: + stats["staff_matched"] += 1 + else: + stats["failed"] += 1 + logger.warning(f"Failed to process rating: {result.get('message')}") + + # Update job progress every 100 records + if job and idx % 100 == 0: + job.processed_count = idx + job.success_count = stats["success"] + job.failed_count = stats["failed"] + job.save() + logger.info(f"Progress: {idx}/{stats['total']} processed") + + except Exception as e: + stats["failed"] += 1 + logger.error(f"Error processing rating {idx}: {e}", exc_info=True) + + # Finalize job + if job: + job.processed_count = stats["total"] + job.success_count = stats["success"] + job.failed_count = stats["failed"] + job.completed_at = timezone.now() + + if stats["failed"] == 0: + job.status = DoctorRatingImportJob.JobStatus.COMPLETED + elif stats["success"] == 0: + job.status = DoctorRatingImportJob.JobStatus.FAILED + else: + job.status = DoctorRatingImportJob.JobStatus.PARTIAL + + job.results = {"stats": stats} + job.save() + + logger.info( + f"Completed monthly HIS doctor rating fetch for {month_label}: " + f"{stats['success']} success, {stats['failed']} failed, {stats['duplicates']} duplicates" + ) + + return { + "success": True, + "month": month_label, + "total_ratings": stats["total"], + "success_count": stats["success"], + "failed_count": stats["failed"], + "duplicate_count": stats["duplicates"], + "staff_matched_count": stats["staff_matched"], + } + + except Exception as exc: + logger.error(f"Error in monthly HIS doctor rating fetch: {exc}", exc_info=True) + # Retry the task + raise self.retry(exc=exc) diff --git a/apps/physicians/ui_views.py b/apps/physicians/ui_views.py index f01eeeb..6d3e427 100644 --- a/apps/physicians/ui_views.py +++ b/apps/physicians/ui_views.py @@ -122,9 +122,9 @@ def leaderboard(request): month = int(request.GET.get("month", now.month)) # Base queryset for ratings - queryset = PhysicianMonthlyRating.objects.filter( - year=year, month=month, staff__physician=True - ).select_related("staff", "staff__hospital", "staff__department") + queryset = PhysicianMonthlyRating.objects.filter(year=year, month=month, staff__physician=True).select_related( + "staff", "staff__hospital", "staff__department" + ) # Apply RBAC filters user = request.user @@ -204,6 +204,7 @@ def physician_ratings_dashboard(request): # Get departments for filter dropdown from apps.organizations.models import Department + if request.user.is_px_admin(): departments = Department.objects.filter(status="active").order_by("name") elif request.user.hospital: @@ -536,7 +537,7 @@ def specialization_overview(request): def physician_detail(request, pk): """ Physician detail view - shows detailed information and ratings for a specific physician. - + Features: - Physician profile information - Monthly ratings history @@ -544,39 +545,39 @@ def physician_detail(request, pk): - Statistics and achievements """ from django.shortcuts import get_object_or_404 - + # Get the physician (staff member marked as physician) physician = get_object_or_404( - Staff.objects.filter(Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN)) - .select_related('hospital', 'department'), - pk=pk + Staff.objects.filter(Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN)).select_related( + "hospital", "department" + ), + pk=pk, ) - + # Check permissions user = request.user if not user.is_px_admin() and user.hospital and physician.hospital != user.hospital: from django.core.exceptions import PermissionDenied + raise PermissionDenied("You don't have permission to view this physician.") - + # Get all monthly ratings for this physician - ratings = PhysicianMonthlyRating.objects.filter( - staff=physician - ).order_by('-year', '-month') - + ratings = PhysicianMonthlyRating.objects.filter(staff=physician).order_by("-year", "-month") + # Get current month rating now = timezone.now() current_rating = ratings.filter(year=now.year, month=now.month).first() - + # Calculate statistics stats = ratings.aggregate( - total_months=Count('id'), - average_rating=Avg('average_rating'), - total_surveys=Sum('total_surveys'), - total_positive=Sum('positive_count'), - total_neutral=Sum('neutral_count'), - total_negative=Sum('negative_count'), + total_months=Count("id"), + average_rating=Avg("average_rating"), + total_surveys=Sum("total_surveys"), + total_positive=Sum("positive_count"), + total_neutral=Sum("neutral_count"), + total_negative=Sum("negative_count"), ) - + # Get trend data (last 12 months) trend_data = [] for i in range(11, -1, -1): @@ -585,23 +586,25 @@ def physician_detail(request, pk): if m <= 0: m += 12 y -= 1 - + rating = ratings.filter(year=y, month=m).first() - trend_data.append({ - 'period': f"{y}-{m:02d}", - 'rating': float(rating.average_rating) if rating else None, - 'surveys': rating.total_surveys if rating else 0, - }) - + trend_data.append( + { + "period": f"{y}-{m:02d}", + "rating": float(rating.average_rating) if rating else None, + "surveys": rating.total_surveys if rating else 0, + } + ) + context = { - 'physician': physician, - 'current_rating': current_rating, - 'ratings': ratings[:12], # Last 12 months - 'stats': stats, - 'trend_data': trend_data, + "physician": physician, + "current_rating": current_rating, + "ratings": ratings[:12], # Last 12 months + "stats": stats, + "trend_data": trend_data, } - - return render(request, 'physicians/physician_detail.html', context) + + return render(request, "physicians/physician_detail.html", context) @login_required @@ -691,3 +694,111 @@ def department_overview(request): } return render(request, "physicians/department_overview.html", context) + + +@login_required +def ratings_export(request): + """Export physician monthly ratings (Step 1 - Calculations format).""" + from django.core.exceptions import PermissionDenied + + if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + raise PermissionDenied + + from .models import PhysicianMonthlyRating + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from django.http import HttpResponse + + year = request.GET.get("year") + month = request.GET.get("month") + quarter = request.GET.get("quarter") + patient_type = request.GET.get("patient_type", "OP") + + qs = PhysicianMonthlyRating.objects.select_related("staff", "staff__department", "staff__hospital") + + if year: + qs = qs.filter(year=int(year)) + if month: + qs = qs.filter(month=int(month)) + elif quarter: + quarter_months = {1: (1, 3), 2: (4, 6), 3: (7, 9), 4: (10, 12)} + start_m, end_m = quarter_months[int(quarter)] + qs = qs.filter(month__gte=start_m, month__lte=end_m) + + if request.user.is_hospital_admin() and request.user.hospital: + qs = qs.filter(staff__hospital=request.user.hospital) + elif request.user.is_px_admin() and request.tenant_hospital: + qs = qs.filter(staff__hospital=request.tenant_hospital) + + qs = qs.order_by("-average_rating") + + wb = Workbook() + ws = wb.active + label = f"Q{quarter}-{year}" if quarter else f"{year}-{month}" if month else f"{year}" if year else "all" + ws.title = f"Ratings {label}" + + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + thin_border = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), + ) + + headers = [ + "Clinic/Department", + "Doctor ID", + "Doctor Name", + "Total Ratings", + "Average Rating", + "1-Star", + "2-Star", + "3-Star", + "4-Star", + "5-Star", + "1-Star %", + "2-Star %", + "3-Star %", + "4-Star %", + "5-Star %", + ] + for col, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=h) + cell.font = header_font + cell.fill = header_fill + cell.border = thin_border + cell.alignment = Alignment(horizontal="center") + + row = 2 + for rating in qs: + total = rating.total_surveys or 1 + dept = rating.staff.department.name if rating.staff.department else "" + doc_id = rating.staff.license_number or "" + doc_name = rating.staff.get_full_name() + + star_counts = [ + rating.negative_count or 0, + 0, + rating.neutral_count or 0, + rating.positive_count or 0, + 0, + ] + star_pcts = [round(c / total * 100, 1) for c in star_counts] + + vals = [dept, doc_id, doc_name, total, float(rating.average_rating)] + star_counts + star_pcts + for col, val in enumerate(vals, 1): + cell = ws.cell(row=row, column=col, value=val) + cell.border = thin_border + row += 1 + + ws.column_dimensions["A"].width = 30 + ws.column_dimensions["B"].width = 15 + ws.column_dimensions["C"].width = 30 + for c in "DEFGHIJKLMNO": + ws.column_dimensions[c].width = 12 + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = f'attachment; filename="physician_ratings_{patient_type}_{label}.xlsx"' + wb.save(response) + return response diff --git a/apps/physicians/urls.py b/apps/physicians/urls.py index e094f03..313d027 100644 --- a/apps/physicians/urls.py +++ b/apps/physicians/urls.py @@ -1,63 +1,57 @@ """ Physicians URL Configuration """ + from django.urls import path from rest_framework.routers import DefaultRouter from . import api_views, import_views, ui_views, views -app_name = 'physicians' +app_name = "physicians" # API Router router = DefaultRouter() -router.register(r'api/physicians', views.PhysicianViewSet, basename='physician') -router.register(r'api/physicians/ratings', views.PhysicianMonthlyRatingViewSet, basename='physician-rating') +router.register(r"api/physicians", views.PhysicianViewSet, basename="physician") +router.register(r"api/physicians/ratings", views.PhysicianMonthlyRatingViewSet, basename="physician-rating") # UI URL patterns urlpatterns = [ # Overview pages - path('overview/specialization/', ui_views.specialization_overview, name='specialization_overview'), - path('overview/department/', ui_views.department_overview, name='department_overview'), - + path("overview/specialization/", ui_views.specialization_overview, name="specialization_overview"), + path("overview/department/", ui_views.department_overview, name="department_overview"), # Physician management - path('', ui_views.physician_list, name='physician_list'), - path('/', ui_views.physician_detail, name='physician_detail'), - + path("", ui_views.physician_list, name="physician_list"), + path("/", ui_views.physician_detail, name="physician_detail"), # Leaderboard - path('leaderboard/', ui_views.leaderboard, name='leaderboard'), - + path("leaderboard/", ui_views.leaderboard, name="leaderboard"), # Dashboard - path('dashboard/', ui_views.physician_ratings_dashboard, name='physician_ratings_dashboard'), - path('api/dashboard/', ui_views.physician_ratings_dashboard_api, name='physician_ratings_dashboard_api'), - + path("dashboard/", ui_views.physician_ratings_dashboard, name="physician_ratings_dashboard"), + path("api/dashboard/", ui_views.physician_ratings_dashboard_api, name="physician_ratings_dashboard_api"), # Monthly Ratings - path('ratings/', ui_views.ratings_list, name='ratings_list'), - + path("ratings/", ui_views.ratings_list, name="ratings_list"), + path("ratings/export/", ui_views.ratings_export, name="ratings_export"), # Individual Ratings & Import - path('individual-ratings/', import_views.individual_ratings_list, name='individual_ratings_list'), - + path("individual-ratings/", import_views.individual_ratings_list, name="individual_ratings_list"), # Doctor Rating Import (CSV Upload) - path('import/', import_views.doctor_rating_import, name='doctor_rating_import'), - path('import/review/', import_views.doctor_rating_review, name='doctor_rating_review'), - path('import/jobs/', import_views.doctor_rating_job_list, name='doctor_rating_job_list'), - path('import/jobs//', import_views.doctor_rating_job_status, name='doctor_rating_job_status'), - + path("import/", import_views.doctor_rating_import, name="doctor_rating_import"), + path("import/review/", import_views.doctor_rating_review, name="doctor_rating_review"), + path("import/jobs/", import_views.doctor_rating_job_list, name="doctor_rating_job_list"), + path("import/jobs//", import_views.doctor_rating_job_status, name="doctor_rating_job_status"), # API Endpoints for Doctor Rating Import # Single rating import (authenticated) - path('api/ratings/import/single/', api_views.import_single_rating, name='api_import_single_rating'), + path("api/ratings/import/single/", api_views.import_single_rating, name="api_import_single_rating"), # Bulk rating import (authenticated, background processing) - path('api/ratings/import/bulk/', api_views.import_bulk_ratings, name='api_import_bulk_ratings'), + path("api/ratings/import/bulk/", api_views.import_bulk_ratings, name="api_import_bulk_ratings"), # Import job status - path('api/ratings/import/jobs/', api_views.import_job_list, name='api_import_job_list'), - path('api/ratings/import/jobs//', api_views.import_job_status, name='api_import_job_status'), + path("api/ratings/import/jobs/", api_views.import_job_list, name="api_import_job_list"), + path("api/ratings/import/jobs//", api_views.import_job_status, name="api_import_job_status"), # HIS-compatible endpoint (for direct HIS integration) - path('api/ratings/his/', api_views.his_doctor_rating_handler, name='api_his_doctor_rating'), + path("api/ratings/his/", api_views.his_doctor_rating_handler, name="api_his_doctor_rating"), # Trigger monthly aggregation - path('api/ratings/aggregate/', api_views.trigger_monthly_aggregation, name='api_trigger_aggregation'), - + path("api/ratings/aggregate/", api_views.trigger_monthly_aggregation, name="api_trigger_aggregation"), # AJAX endpoints - path('api/jobs//progress/', import_views.api_job_progress, name='api_job_progress'), - path('api/match-doctor/', import_views.api_match_doctor, name='api_match_doctor'), + path("api/jobs//progress/", import_views.api_job_progress, name="api_job_progress"), + path("api/match-doctor/", import_views.api_match_doctor, name="api_match_doctor"), ] # Add API routes diff --git a/apps/px_action_center/admin.py b/apps/px_action_center/admin.py index 994fbdc..68343d2 100644 --- a/apps/px_action_center/admin.py +++ b/apps/px_action_center/admin.py @@ -1,286 +1,257 @@ """ PX Action Center admin """ + from django.contrib import admin -from django.utils.html import format_html +from django.utils.html import format_html, mark_safe from .models import PXAction, PXActionAttachment, PXActionLog, PXActionSLAConfig, RoutingRule class PXActionLogInline(admin.TabularInline): """Inline admin for action logs""" + model = PXActionLog extra = 1 - fields = ['log_type', 'message', 'created_by', 'created_at'] - readonly_fields = ['created_at'] - ordering = ['-created_at'] + fields = ["log_type", "message", "created_by", "created_at"] + readonly_fields = ["created_at"] + ordering = ["-created_at"] class PXActionAttachmentInline(admin.TabularInline): """Inline admin for action attachments""" + model = PXActionAttachment extra = 0 - fields = ['file', 'filename', 'is_evidence', 'uploaded_by', 'description'] - readonly_fields = ['file_size'] + fields = ["file", "filename", "is_evidence", "uploaded_by", "description"] + readonly_fields = ["file_size"] @admin.register(PXAction) class PXActionAdmin(admin.ModelAdmin): """PX Action admin""" + list_display = [ - 'title_preview', 'source_type', 'hospital', 'category', - 'severity_badge', 'status_badge', 'sla_indicator', - 'assigned_to', 'escalation_level', 'created_at' + "title_preview", + "source_type", + "hospital", + "category", + "severity_badge", + "status_badge", + "sla_indicator", + "assigned_to", + "escalation_level", + "created_at", ] list_filter = [ - 'status', 'source_type', 'severity', 'priority', 'category', - 'is_overdue', 'requires_approval', 'hospital', 'created_at' + "status", + "source_type", + "severity", + "priority", + "category", + "is_overdue", + "requires_approval", + "hospital", + "created_at", ] - search_fields = ['title', 'description', 'metadata'] - ordering = ['-created_at'] - date_hierarchy = 'created_at' + search_fields = ["title", "description", "metadata"] + ordering = ["-created_at"] + date_hierarchy = "created_at" inlines = [PXActionLogInline, PXActionAttachmentInline] - + fieldsets = ( - ('Source', { - 'fields': ('source_type', 'content_type', 'object_id') - }), - ('Action Details', { - 'fields': ('title', 'description', 'category') - }), - ('Organization', { - 'fields': ('hospital', 'department') - }), - ('Classification', { - 'fields': ('priority', 'severity') - }), - ('Status & Assignment', { - 'fields': ('status', 'assigned_to', 'assigned_at') - }), - ('SLA Tracking', { - 'fields': ('due_at', 'is_overdue', 'reminder_sent_at', 'escalated_at', 'escalation_level') - }), - ('Approval', { - 'fields': ('requires_approval', 'approved_by', 'approved_at') - }), - ('Closure', { - 'fields': ('closed_at', 'closed_by') - }), - ('Action Plan & Outcome', { - 'fields': ('action_plan', 'outcome'), - 'classes': ('collapse',) - }), - ('Metadata', { - 'fields': ('metadata', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ("Source", {"fields": ("source_type", "content_type", "object_id")}), + ("Action Details", {"fields": ("title", "description", "category")}), + ("Organization", {"fields": ("hospital", "department")}), + ("Classification", {"fields": ("priority", "severity")}), + ("Status & Assignment", {"fields": ("status", "assigned_to", "assigned_at")}), + ("SLA Tracking", {"fields": ("due_at", "is_overdue", "reminder_sent_at", "escalated_at", "escalation_level")}), + ("Approval", {"fields": ("requires_approval", "approved_by", "approved_at")}), + ("Closure", {"fields": ("closed_at", "closed_by")}), + ("Action Plan & Outcome", {"fields": ("action_plan", "outcome"), "classes": ("collapse",)}), + ("Metadata", {"fields": ("metadata", "created_at", "updated_at"), "classes": ("collapse",)}), ) - + readonly_fields = [ - 'assigned_at', 'reminder_sent_at', 'escalated_at', - 'approved_at', 'closed_at', - 'created_at', 'updated_at' + "assigned_at", + "reminder_sent_at", + "escalated_at", + "approved_at", + "closed_at", + "created_at", + "updated_at", ] - + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related( - 'hospital', 'department', 'assigned_to', - 'approved_by', 'closed_by', 'content_type' - ) - + return qs.select_related("hospital", "department", "assigned_to", "approved_by", "closed_by", "content_type") + def title_preview(self, obj): """Show preview of title""" - return obj.title[:60] + '...' if len(obj.title) > 60 else obj.title - title_preview.short_description = 'Title' - + return obj.title[:60] + "..." if len(obj.title) > 60 else obj.title + + title_preview.short_description = "Title" + def severity_badge(self, obj): """Display severity with color badge""" colors = { - 'low': 'info', - 'medium': 'warning', - 'high': 'danger', - 'critical': 'danger', + "low": "info", + "medium": "warning", + "high": "danger", + "critical": "danger", } - color = colors.get(obj.severity, 'secondary') - return format_html( - '{}', - color, - obj.get_severity_display() - ) - severity_badge.short_description = 'Severity' - + color = colors.get(obj.severity, "secondary") + return format_html('{}', color, obj.get_severity_display()) + + severity_badge.short_description = "Severity" + def status_badge(self, obj): """Display status with color badge""" colors = { - 'open': 'danger', - 'in_progress': 'warning', - 'pending_approval': 'info', - 'approved': 'success', - 'closed': 'success', - 'cancelled': 'secondary', + "open": "danger", + "in_progress": "warning", + "pending_approval": "info", + "approved": "success", + "closed": "success", + "cancelled": "secondary", } - color = colors.get(obj.status, 'secondary') - return format_html( - '{}', - color, - obj.get_status_display() - ) - status_badge.short_description = 'Status' - + color = colors.get(obj.status, "secondary") + return format_html('{}', color, obj.get_status_display()) + + status_badge.short_description = "Status" + def sla_indicator(self, obj): """Display SLA status""" if obj.is_overdue: - return format_html( - 'OVERDUE (Level {})', - obj.escalation_level - ) - + return format_html('OVERDUE (Level {})', obj.escalation_level) + from django.utils import timezone + time_remaining = obj.due_at - timezone.now() hours_remaining = time_remaining.total_seconds() / 3600 - + if hours_remaining < 4: - return format_html('DUE SOON') + return mark_safe('DUE SOON') else: - return format_html('ON TIME') - sla_indicator.short_description = 'SLA' + return mark_safe('ON TIME') + + sla_indicator.short_description = "SLA" @admin.register(PXActionLog) class PXActionLogAdmin(admin.ModelAdmin): """PX Action log admin""" - list_display = ['action', 'log_type', 'message_preview', 'created_by', 'created_at'] - list_filter = ['log_type', 'created_at'] - search_fields = ['message', 'action__title'] - ordering = ['-created_at'] - + + list_display = ["action", "log_type", "message_preview", "created_by", "created_at"] + list_filter = ["log_type", "created_at"] + search_fields = ["message", "action__title"] + ordering = ["-created_at"] + fieldsets = ( - (None, { - 'fields': ('action', 'log_type', 'message') - }), - ('Status Change', { - 'fields': ('old_status', 'new_status'), - 'classes': ('collapse',) - }), - ('Details', { - 'fields': ('created_by', 'metadata') - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("action", "log_type", "message")}), + ("Status Change", {"fields": ("old_status", "new_status"), "classes": ("collapse",)}), + ("Details", {"fields": ("created_by", "metadata")}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('action', 'created_by') - + return qs.select_related("action", "created_by") + def message_preview(self, obj): """Show preview of message""" - return obj.message[:100] + '...' if len(obj.message) > 100 else obj.message - message_preview.short_description = 'Message' + return obj.message[:100] + "..." if len(obj.message) > 100 else obj.message + + message_preview.short_description = "Message" @admin.register(PXActionAttachment) class PXActionAttachmentAdmin(admin.ModelAdmin): """PX Action attachment admin""" - list_display = ['action', 'filename', 'is_evidence', 'file_size', 'uploaded_by', 'created_at'] - list_filter = ['is_evidence', 'file_type', 'created_at'] - search_fields = ['filename', 'description', 'action__title'] - ordering = ['-created_at'] - + + list_display = ["action", "filename", "is_evidence", "file_size", "uploaded_by", "created_at"] + list_filter = ["is_evidence", "file_type", "created_at"] + search_fields = ["filename", "description", "action__title"] + ordering = ["-created_at"] + fieldsets = ( - (None, { - 'fields': ('action', 'file', 'filename', 'file_type', 'file_size') - }), - ('Details', { - 'fields': ('is_evidence', 'uploaded_by', 'description') - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("action", "file", "filename", "file_type", "file_size")}), + ("Details", {"fields": ("is_evidence", "uploaded_by", "description")}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['file_size', 'created_at', 'updated_at'] - + + readonly_fields = ["file_size", "created_at", "updated_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('action', 'uploaded_by') + return qs.select_related("action", "uploaded_by") @admin.register(PXActionSLAConfig) class PXActionSLAConfigAdmin(admin.ModelAdmin): """PX Action SLA configuration admin""" + list_display = [ - 'name', 'hospital', 'department', - 'critical_hours', 'high_hours', 'medium_hours', 'low_hours', - 'auto_escalate', 'is_active' + "name", + "hospital", + "department", + "critical_hours", + "high_hours", + "medium_hours", + "low_hours", + "auto_escalate", + "is_active", ] - list_filter = ['is_active', 'auto_escalate', 'hospital'] - search_fields = ['name'] - ordering = ['hospital', 'name'] - + list_filter = ["is_active", "auto_escalate", "hospital"] + search_fields = ["name"] + ordering = ["hospital", "name"] + fieldsets = ( - (None, { - 'fields': ('name', 'hospital', 'department') - }), - ('SLA Durations (hours)', { - 'fields': ('critical_hours', 'high_hours', 'medium_hours', 'low_hours') - }), - ('Reminder Configuration', { - 'fields': ('reminder_hours_before',) - }), - ('Escalation Configuration', { - 'fields': ('auto_escalate', 'escalation_delay_hours') - }), - ('Status', { - 'fields': ('is_active',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("name", "hospital", "department")}), + ("SLA Durations (hours)", {"fields": ("critical_hours", "high_hours", "medium_hours", "low_hours")}), + ("Reminder Configuration", {"fields": ("reminder_hours_before",)}), + ("Escalation Configuration", {"fields": ("auto_escalate", "escalation_delay_hours")}), + ("Status", {"fields": ("is_active",)}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('hospital', 'department') + return qs.select_related("hospital", "department") @admin.register(RoutingRule) class RoutingRuleAdmin(admin.ModelAdmin): """Routing rule admin""" + list_display = [ - 'name', 'source_type', 'category', 'severity', - 'hospital', 'assign_to_role', 'priority', 'is_active' + "name", + "source_type", + "category", + "severity", + "hospital", + "assign_to_role", + "priority", + "is_active", ] - list_filter = ['source_type', 'severity', 'is_active', 'hospital'] - search_fields = ['name', 'description'] - ordering = ['-priority', 'name'] - + list_filter = ["source_type", "severity", "is_active", "hospital"] + search_fields = ["name", "description"] + ordering = ["-priority", "name"] + fieldsets = ( - (None, { - 'fields': ('name', 'description', 'priority') - }), - ('Conditions', { - 'fields': ('source_type', 'category', 'severity', 'hospital', 'department') - }), - ('Routing Target', { - 'fields': ('assign_to_role', 'assign_to_user', 'assign_to_department') - }), - ('Status', { - 'fields': ('is_active',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("name", "description", "priority")}), + ("Conditions", {"fields": ("source_type", "category", "severity", "hospital", "department")}), + ("Routing Target", {"fields": ("assign_to_role", "assign_to_user", "assign_to_department")}), + ("Status", {"fields": ("is_active",)}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('hospital', 'department', 'assign_to_user', 'assign_to_department') + return qs.select_related("hospital", "department", "assign_to_user", "assign_to_department") diff --git a/apps/standards/urls.py b/apps/standards/urls.py index 0b8172b..988fc31 100644 --- a/apps/standards/urls.py +++ b/apps/standards/urls.py @@ -20,6 +20,9 @@ from apps.standards.views import ( get_compliance_status, create_compliance_ajax, update_compliance_ajax, + get_attachments_ajax, + upload_attachment_ajax, + delete_attachment_ajax, source_list, source_create, source_update, @@ -45,6 +48,10 @@ urlpatterns = [ path("api/compliance/create/", create_compliance_ajax, name="compliance_create_ajax"), path("api/compliance/update/", update_compliance_ajax, name="compliance_update_ajax"), path("api/compliance///", get_compliance_status, name="compliance_status"), + # Attachment AJAX endpoints + path("api/attachments/list//", get_attachments_ajax, name="attachments_list_ajax"), + path("api/attachments/upload/", upload_attachment_ajax, name="attachment_upload_ajax"), + path("api/attachments//delete/", delete_attachment_ajax, name="attachment_delete_ajax"), # API endpoints (router) path("api/", include(router.urls)), # UI Views diff --git a/apps/standards/views.py b/apps/standards/views.py index fe59eff..ff88605 100644 --- a/apps/standards/views.py +++ b/apps/standards/views.py @@ -569,6 +569,133 @@ def get_compliance_status(request, department_id, standard_id): return JsonResponse(data) +@ensure_csrf_cookie +@login_required +def get_attachments_ajax(request, compliance_id): + """Get all attachments for a compliance record via AJAX""" + if not request.user.is_authenticated: + return JsonResponse({"success": False, "error": "Authentication required"}, status=401) + + compliance = get_object_or_404(StandardCompliance, pk=compliance_id) + + attachments = compliance.attachments.select_related("uploaded_by").order_by("-created_at") + + attachments_data = [] + for attachment in attachments: + attachments_data.append( + { + "id": str(attachment.id), + "filename": attachment.filename, + "description": attachment.description or "", + "file_url": attachment.file.url, + "uploaded_by": attachment.uploaded_by.get_full_name() if attachment.uploaded_by else "Unknown", + "uploaded_at": attachment.created_at.strftime("%Y-%m-%d %H:%M"), + } + ) + + return JsonResponse( + { + "success": True, + "compliance_id": str(compliance.id), + "standard_code": compliance.standard.code, + "standard_title": compliance.standard.title, + "attachments": attachments_data, + "attachment_count": len(attachments_data), + } + ) + + +@ensure_csrf_cookie +@login_required +def upload_attachment_ajax(request): + """Upload attachment for compliance via AJAX""" + if not request.user.is_authenticated: + return JsonResponse({"success": False, "error": "Authentication required"}, status=401) + + if request.method != "POST": + return JsonResponse({"success": False, "error": "Only POST method allowed"}) + + compliance_id = request.POST.get("compliance_id") + description = request.POST.get("description", "") + + if not compliance_id: + return JsonResponse({"success": False, "error": "Missing compliance_id"}) + + if "file" not in request.FILES: + return JsonResponse({"success": False, "error": "No file provided"}) + + compliance = get_object_or_404(StandardCompliance, pk=compliance_id) + uploaded_file = request.FILES["file"] + + # Validate file size (max 50MB) + if uploaded_file.size > 50 * 1024 * 1024: + return JsonResponse({"success": False, "error": "File size must be less than 50MB"}) + + # Validate file type + allowed_extensions = [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".jpg", ".jpeg", ".png", ".zip"] + file_ext = "." + uploaded_file.name.split(".")[-1].lower() if "." in uploaded_file.name else "" + + if file_ext not in allowed_extensions: + return JsonResponse( + { + "success": False, + "error": f"Invalid file type. Allowed: {', '.join(allowed_extensions)}", + } + ) + + try: + attachment = StandardAttachment.objects.create( + compliance=compliance, + file=uploaded_file, + filename=uploaded_file.name, + description=description, + uploaded_by=request.user, + ) + + return JsonResponse( + { + "success": True, + "attachment": { + "id": str(attachment.id), + "filename": attachment.filename, + "description": attachment.description or "", + "file_url": attachment.file.url, + "uploaded_by": request.user.get_full_name() if request.user else "Unknown", + "uploaded_at": attachment.created_at.strftime("%Y-%m-%d %H:%M"), + }, + "attachment_count": compliance.attachments.count(), + } + ) + except Exception as e: + import traceback + + traceback.print_exc() + return JsonResponse({"success": False, "error": str(e)}) + + +@ensure_csrf_cookie +@login_required +def delete_attachment_ajax(request, attachment_id): + """Delete an attachment via AJAX""" + if not request.user.is_authenticated: + return JsonResponse({"success": False, "error": "Authentication required"}, status=401) + + if request.method != "POST": + return JsonResponse({"success": False, "error": "Only POST method allowed"}) + + attachment = get_object_or_404(StandardAttachment, pk=attachment_id) + compliance = attachment.compliance + + try: + attachment.delete() + return JsonResponse({"success": True, "attachment_count": compliance.attachments.count()}) + except Exception as e: + import traceback + + traceback.print_exc() + return JsonResponse({"success": False, "error": str(e)}) + + # ==================== Source Management Views ==================== diff --git a/apps/surveys/admin.py b/apps/surveys/admin.py index 73d4e80..8899086 100644 --- a/apps/surveys/admin.py +++ b/apps/surveys/admin.py @@ -1,121 +1,115 @@ """ Surveys admin """ + from django.contrib import admin from django.utils.html import format_html from django.db.models import Count -from .models import SurveyInstance, SurveyQuestion, SurveyResponse, SurveyTemplate, SurveyTracking +from .models import ( + SurveyInstance, + SurveyQuestion, + SurveyResponse, + SurveyTemplate, + SurveyTracking, +) class SurveyQuestionInline(admin.TabularInline): """Inline admin for survey questions""" + model = SurveyQuestion extra = 1 - fields = ['order', 'text', 'question_type', 'is_required'] - ordering = ['order'] + fields = ["order", "text", "question_type", "is_required", "is_base", "event_type"] + ordering = ["order"] @admin.register(SurveyTemplate) class SurveyTemplateAdmin(admin.ModelAdmin): """Survey template admin""" + list_display = [ - 'name', 'survey_type', 'hospital', 'scoring_method', - 'negative_threshold', 'get_question_count', 'is_active' + "name", + "survey_type", + "hospital", + "scoring_method", + "negative_threshold", + "get_question_count", + "is_active", ] - list_filter = ['survey_type', 'scoring_method', 'is_active', 'hospital'] - search_fields = ['name', 'name_ar'] - ordering = ['hospital', 'name'] + list_filter = ["survey_type", "scoring_method", "is_active", "hospital"] + search_fields = ["name", "name_ar"] + ordering = ["hospital", "name"] inlines = [SurveyQuestionInline] - + fieldsets = ( - (None, { - 'fields': ('name', 'name_ar') - }), - ('Configuration', { - 'fields': ('hospital', 'survey_type') - }), - ('Scoring', { - 'fields': ('scoring_method', 'negative_threshold') - }), - ('Status', { - 'fields': ('is_active',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("name", "name_ar")}), + ("Configuration", {"fields": ("hospital", "survey_type")}), + ("Scoring", {"fields": ("scoring_method", "negative_threshold")}), + ("Status", {"fields": ("is_active",)}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('hospital').prefetch_related('questions') + return qs.select_related("hospital").prefetch_related("questions") @admin.register(SurveyQuestion) class SurveyQuestionAdmin(admin.ModelAdmin): """Survey question admin""" - list_display = [ - 'survey_template', 'order', 'text_preview', - 'question_type', 'is_required' - ] - list_filter = ['survey_template', 'question_type', 'is_required'] - search_fields = ['text', 'text_ar'] - ordering = ['survey_template', 'order'] - + + list_display = ["survey_template", "order", "text_preview", "question_type", "is_base", "is_required"] + list_filter = ["survey_template", "question_type", "is_required", "is_base"] + search_fields = ["text", "text_ar"] + ordering = ["survey_template", "order"] + fieldsets = ( - (None, { - 'fields': ('survey_template', 'order') - }), - ('Question Text', { - 'fields': ('text', 'text_ar') - }), - ('Configuration', { - 'fields': ('question_type', 'is_required') - }), - ('Choices (for multiple choice)', { - 'fields': ('choices_json',), - 'classes': ('collapse',) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("survey_template", "order")}), + ("Question Text", {"fields": ("text", "text_ar")}), + ("Configuration", {"fields": ("question_type", "is_required", "is_base", "event_type")}), + ("Choices (for multiple choice)", {"fields": ("choices_json",), "classes": ("collapse",)}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def text_preview(self, obj): """Show preview of question text""" - return obj.text[:100] + '...' if len(obj.text) > 100 else obj.text - text_preview.short_description = 'Question' - + return obj.text[:100] + "..." if len(obj.text) > 100 else obj.text + + text_preview.short_description = "Question" + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('survey_template') + return qs.select_related("survey_template") class SurveyResponseInline(admin.TabularInline): """Inline admin for survey responses""" + model = SurveyResponse extra = 0 - fields = ['question', 'numeric_value', 'text_value', 'choice_value'] - readonly_fields = ['question'] - ordering = ['question__order'] - + fields = ["question", "numeric_value", "text_value", "choice_value"] + readonly_fields = ["question"] + ordering = ["question__order"] + def has_add_permission(self, request, obj=None): return False class SurveyTrackingInline(admin.TabularInline): """Inline admin for survey tracking events""" + model = SurveyTracking extra = 0 - fields = ['event_type', 'device_type', 'browser', 'total_time_spent', 'created_at'] - readonly_fields = ['event_type', 'device_type', 'browser', 'total_time_spent', 'created_at'] - ordering = ['-created_at'] + fields = ["event_type", "device_type", "browser", "total_time_spent", "created_at"] + readonly_fields = ["event_type", "device_type", "browser", "total_time_spent", "created_at"] + ordering = ["-created_at"] can_delete = False - + def has_add_permission(self, request, obj=None): return False @@ -123,229 +117,216 @@ class SurveyTrackingInline(admin.TabularInline): @admin.register(SurveyInstance) class SurveyInstanceAdmin(admin.ModelAdmin): """Survey instance admin""" + list_display = [ - 'survey_template', 'patient', 'encounter_id', - 'status_badge', 'delivery_channel', 'open_count', - 'time_spent_display', 'total_score', - 'is_negative', 'sent_at', 'completed_at' + "survey_label", + "patient", + "encounter_id", + "status_badge", + "delivery_channel", + "open_count", + "time_spent_display", + "total_score", + "is_negative", + "sent_at", + "completed_at", ] list_filter = [ - 'status', 'delivery_channel', 'is_negative', - 'survey_template__survey_type', 'sent_at', 'completed_at' + "status", + "delivery_channel", + "is_negative", + "survey_template__survey_type", + "sent_at", + "completed_at", ] - search_fields = [ - 'patient__mrn', 'patient__first_name', 'patient__last_name', - 'encounter_id', 'access_token' - ] - ordering = ['-created_at'] + search_fields = ["patient__mrn", "patient__first_name", "patient__last_name", "encounter_id", "access_token"] + ordering = ["-created_at"] inlines = [SurveyResponseInline, SurveyTrackingInline] - + fieldsets = ( - (None, { - 'fields': ('survey_template', 'patient', 'encounter_id') - }), - ('Journey Linkage', { - 'fields': ('journey_instance',), - 'classes': ('collapse',) - }), - ('Delivery', { - 'fields': ( - 'delivery_channel', 'recipient_phone', 'recipient_email', - 'access_token', 'token_expires_at' - ) - }), - ('Status & Timestamps', { - 'fields': ('status', 'sent_at', 'opened_at', 'completed_at') - }), - ('Tracking', { - 'fields': ('open_count', 'last_opened_at', 'time_spent_seconds') - }), - ('Scoring', { - 'fields': ('total_score', 'is_negative') - }), - ('Patient Comment', { - 'fields': ('comment', 'comment_analyzed') - }), - ('Comment Analysis', { - 'fields': ('comment_analysis',), - 'classes': ('collapse',) - }), - ('Metadata', { - 'fields': ('metadata', 'created_at', 'updated_at'), - 'classes': ('collapse',) - }), + (None, {"fields": ("survey_template", "patient", "encounter_id")}), + ("Journey Linkage", {"fields": ("journey_instance",), "classes": ("collapse",)}), + ( + "Delivery", + {"fields": ("delivery_channel", "recipient_phone", "recipient_email", "access_token", "token_expires_at")}, + ), + ("Status & Timestamps", {"fields": ("status", "sent_at", "opened_at", "completed_at")}), + ("Tracking", {"fields": ("open_count", "last_opened_at", "time_spent_seconds")}), + ("Scoring", {"fields": ("total_score", "is_negative")}), + ("Patient Comment", {"fields": ("comment", "comment_analyzed")}), + ("Comment Analysis", {"fields": ("comment_analysis",), "classes": ("collapse",)}), + ("Metadata", {"fields": ("metadata", "created_at", "updated_at"), "classes": ("collapse",)}), ) - + readonly_fields = [ - 'access_token', 'token_expires_at', 'sent_at', 'opened_at', - 'completed_at', 'open_count', 'last_opened_at', 'time_spent_seconds', - 'total_score', 'is_negative', 'comment_analyzed', 'comment_analysis', - 'created_at', 'updated_at' + "access_token", + "token_expires_at", + "sent_at", + "opened_at", + "completed_at", + "open_count", + "last_opened_at", + "time_spent_seconds", + "total_score", + "is_negative", + "comment_analyzed", + "comment_analysis", + "created_at", + "updated_at", ] - + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related( - 'survey_template', 'patient', 'journey_instance' - ).prefetch_related('responses', 'tracking_events') - + return qs.select_related("survey_template", "patient", "journey_instance").prefetch_related( + "responses", "tracking_events" + ) + def status_badge(self, obj): """Display status with color badge""" colors = { - 'sent': 'secondary', - 'viewed': 'info', - 'in_progress': 'warning', - 'completed': 'success', - 'abandoned': 'danger', - 'expired': 'secondary', - 'cancelled': 'dark', + "sent": "secondary", + "viewed": "info", + "in_progress": "warning", + "completed": "success", + "abandoned": "danger", + "expired": "secondary", + "cancelled": "dark", } - color = colors.get(obj.status, 'secondary') - return format_html( - '{}', - color, - obj.get_status_display() - ) - status_badge.short_description = 'Status' - + color = colors.get(obj.status, "secondary") + return format_html('{}', color, obj.get_status_display()) + + status_badge.short_description = "Status" + + def survey_label(self, obj): + if obj.survey_template: + return obj.survey_template.name + return obj.metadata.get("patient_type", "Event-based Survey") + + survey_label.short_description = "Survey" + def time_spent_display(self, obj): """Display time spent in human-readable format""" if obj.time_spent_seconds: minutes = obj.time_spent_seconds // 60 seconds = obj.time_spent_seconds % 60 return f"{minutes}m {seconds}s" - return '-' - time_spent_display.short_description = 'Time Spent' + return "-" + + time_spent_display.short_description = "Time Spent" @admin.register(SurveyTracking) class SurveyTrackingAdmin(admin.ModelAdmin): """Survey tracking admin""" + list_display = [ - 'survey_instance_link', 'event_type_badge', - 'device_type', 'browser', 'ip_address', - 'total_time_spent_display', 'created_at' - ] - list_filter = [ - 'event_type', 'device_type', 'browser', - 'survey_instance__survey_template', 'created_at' + "survey_instance_link", + "event_type_badge", + "device_type", + "browser", + "ip_address", + "total_time_spent_display", + "created_at", ] + list_filter = ["event_type", "device_type", "browser", "survey_instance__survey_template", "created_at"] search_fields = [ - 'survey_instance__patient__mrn', - 'survey_instance__patient__first_name', - 'survey_instance__patient__last_name', - 'ip_address', 'user_agent' + "survey_instance__patient__mrn", + "survey_instance__patient__first_name", + "survey_instance__patient__last_name", + "ip_address", + "user_agent", ] - ordering = ['-created_at'] - + ordering = ["-created_at"] + fieldsets = ( - (None, { - 'fields': ('survey_instance', 'event_type') - }), - ('Timing', { - 'fields': ('time_on_page', 'total_time_spent') - }), - ('Context', { - 'fields': ('current_question',) - }), - ('Device Info', { - 'fields': ('user_agent', 'ip_address', 'device_type', 'browser') - }), - ('Location', { - 'fields': ('country', 'city'), - 'classes': ('collapse',) - }), - ('Metadata', { - 'fields': ('metadata', 'created_at'), - 'classes': ('collapse',) - }), + (None, {"fields": ("survey_instance", "event_type")}), + ("Timing", {"fields": ("time_on_page", "total_time_spent")}), + ("Context", {"fields": ("current_question",)}), + ("Device Info", {"fields": ("user_agent", "ip_address", "device_type", "browser")}), + ("Location", {"fields": ("country", "city"), "classes": ("collapse",)}), + ("Metadata", {"fields": ("metadata", "created_at"), "classes": ("collapse",)}), ) - - readonly_fields = ['created_at'] - + + readonly_fields = ["created_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related( - 'survey_instance', - 'survey_instance__patient', - 'survey_instance__survey_template' - ) - + return qs.select_related("survey_instance", "survey_instance__patient") + def survey_instance_link(self, obj): """Link to survey instance""" url = f"/admin/surveys/surveyinstance/{obj.survey_instance.id}/change/" - return format_html('{} - {}', url, obj.survey_instance.survey_template.name, obj.survey_instance.patient.get_full_name()) - survey_instance_link.short_description = 'Survey' - + label = ( + obj.survey_instance.survey_template.name + if obj.survey_instance.survey_template + else obj.survey_instance.metadata.get("patient_type", "Survey") + ) + return format_html( + '{} - {}', + url, + label, + obj.survey_instance.patient.get_full_name(), + ) + + survey_instance_link.short_description = "Survey" + def event_type_badge(self, obj): """Display event type with color badge""" colors = { - 'page_view': 'info', - 'survey_started': 'primary', - 'question_answered': 'secondary', - 'survey_completed': 'success', - 'survey_abandoned': 'danger', - 'reminder_sent': 'warning', + "page_view": "info", + "survey_started": "primary", + "question_answered": "secondary", + "survey_completed": "success", + "survey_abandoned": "danger", + "reminder_sent": "warning", } - color = colors.get(obj.event_type, 'secondary') - return format_html( - '{}', - color, - obj.get_event_type_display() - ) - event_type_badge.short_description = 'Event Type' - + color = colors.get(obj.event_type, "secondary") + return format_html('{}', color, obj.get_event_type_display()) + + event_type_badge.short_description = "Event Type" + def total_time_spent_display(self, obj): """Display time spent in human-readable format""" if obj.total_time_spent: minutes = obj.total_time_spent // 60 seconds = obj.total_time_spent % 60 return f"{minutes}m {seconds}s" - return '-' - total_time_spent_display.short_description = 'Time Spent' + return "-" + + total_time_spent_display.short_description = "Time Spent" @admin.register(SurveyResponse) class SurveyResponseAdmin(admin.ModelAdmin): """Survey response admin""" - list_display = [ - 'survey_instance', 'question_preview', - 'numeric_value', 'text_value_preview', 'created_at' - ] - list_filter = ['survey_instance__survey_template', 'question__question_type', 'created_at'] - search_fields = [ - 'survey_instance__patient__mrn', - 'question__text', - 'text_value' - ] - ordering = ['survey_instance', 'question__order'] - + + list_display = ["survey_instance", "question_preview", "numeric_value", "text_value_preview", "created_at"] + list_filter = ["survey_instance__survey_template", "question__question_type", "created_at"] + search_fields = ["survey_instance__patient__mrn", "question__text", "text_value"] + ordering = ["survey_instance", "question__order"] + fieldsets = ( - (None, { - 'fields': ('survey_instance', 'question') - }), - ('Response', { - 'fields': ('numeric_value', 'text_value', 'choice_value') - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at') - }), + (None, {"fields": ("survey_instance", "question")}), + ("Response", {"fields": ("numeric_value", "text_value", "choice_value")}), + ("Metadata", {"fields": ("created_at", "updated_at")}), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('survey_instance', 'question') - + return qs.select_related("survey_instance", "question") + def question_preview(self, obj): """Show preview of question""" - return obj.question.text[:50] + '...' if len(obj.question.text) > 50 else obj.question.text - question_preview.short_description = 'Question' - + return obj.question.text[:50] + "..." if len(obj.question.text) > 50 else obj.question.text + + question_preview.short_description = "Question" + def text_value_preview(self, obj): """Show preview of text response""" if obj.text_value: - return obj.text_value[:50] + '...' if len(obj.text_value) > 50 else obj.text_value - return '-' - text_value_preview.short_description = 'Text Response' + return obj.text_value[:50] + "..." if len(obj.text_value) > 50 else obj.text_value + return "-" + + text_value_preview.short_description = "Text Response" diff --git a/apps/surveys/forms.py b/apps/surveys/forms.py index 7ab9745..e647dcb 100644 --- a/apps/surveys/forms.py +++ b/apps/surveys/forms.py @@ -7,17 +7,15 @@ from django.utils.translation import gettext_lazy as _ from apps.organizations.models import Patient, Staff, Hospital from apps.core.form_mixins import HospitalFieldMixin -from .models import SurveyInstance, SurveyTemplate, SurveyQuestion +from .models import ( + SurveyInstance, + SurveyTemplate, + SurveyQuestion, +) class SurveyTemplateForm(HospitalFieldMixin, forms.ModelForm): - """ - Form for creating/editing survey templates. - - Hospital field visibility: - - PX Admins: See dropdown with all hospitals - - Others: Hidden field, auto-set to user's hospital - """ + """Form for creating/editing survey templates""" class Meta: model = SurveyTemplate @@ -40,7 +38,7 @@ class SurveyQuestionForm(forms.ModelForm): class Meta: model = SurveyQuestion - fields = ["text", "text_ar", "question_type", "order", "is_required", "choices_json"] + fields = ["text", "text_ar", "question_type", "order", "is_required", "is_base", "event_type", "choices_json"] widgets = { "text": forms.Textarea( attrs={"class": "form-control", "rows": 2, "placeholder": "Enter question in English"} @@ -51,6 +49,13 @@ class SurveyQuestionForm(forms.ModelForm): "question_type": forms.Select(attrs={"class": "form-select"}), "order": forms.NumberInput(attrs={"class": "form-control", "min": "0"}), "is_required": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "is_base": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "event_type": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "e.g., Lab Bill, Triage (leave blank for base questions)", + } + ), "choices_json": forms.Textarea( attrs={ "class": "form-control", @@ -67,6 +72,8 @@ class SurveyQuestionForm(forms.ModelForm): "JSON array of choices for multiple choice questions. " 'Format: [{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]' ) + self.fields["event_type"].required = False + self.fields["event_type"].help_text = _("Leave blank for base questions (always included).") SurveyQuestionFormSet = forms.inlineformset_factory( diff --git a/apps/surveys/models.py b/apps/surveys/models.py index 5a10a7c..80413aa 100644 --- a/apps/surveys/models.py +++ b/apps/surveys/models.py @@ -8,6 +8,7 @@ This module implements the survey system that: - Collects and scores survey responses - Triggers actions based on negative feedback """ + import secrets from django.core.signing import Signer @@ -19,24 +20,27 @@ from apps.core.models import BaseChoices, StatusChoices, TenantModel, TimeStampe class SurveyStatus(BaseChoices): """Survey status choices with enhanced tracking""" - SENT = 'sent', 'Sent (Not Opened)' - VIEWED = 'viewed', 'Viewed (Opened, Not Started)' - IN_PROGRESS = 'in_progress', 'In Progress (Started, Not Completed)' - COMPLETED = 'completed', 'Completed' - ABANDONED = 'abandoned', 'Abandoned (Started but Left)' - EXPIRED = 'expired', 'Expired' - CANCELLED = 'cancelled', 'Cancelled' + + PENDING = "pending", "Pending (Scheduled, Not Yet Sent)" + SENT = "sent", "Sent (Not Opened)" + VIEWED = "viewed", "Viewed (Opened, Not Started)" + IN_PROGRESS = "in_progress", "In Progress (Started, Not Completed)" + COMPLETED = "completed", "Completed" + ABANDONED = "abandoned", "Abandoned (Started but Left)" + EXPIRED = "expired", "Expired" + CANCELLED = "cancelled", "Cancelled" class QuestionType(BaseChoices): """Survey question type choices""" - RATING = 'rating', 'Rating (1-5 stars)' - NPS = 'nps', 'NPS (0-10)' - YES_NO = 'yes_no', 'Yes/No' - MULTIPLE_CHOICE = 'multiple_choice', 'Multiple Choice' - TEXT = 'text', 'Text (Short Answer)' - TEXTAREA = 'textarea', 'Text Area (Long Answer)' - LIKERT = 'likert', 'Likert Scale (1-5)' + + RATING = "rating", "Rating (1-5 stars)" + NPS = "nps", "NPS (0-10)" + YES_NO = "yes_no", "Yes/No" + MULTIPLE_CHOICE = "multiple_choice", "Multiple Choice" + TEXT = "text", "Text (Short Answer)" + TEXTAREA = "textarea", "Text Area (Long Answer)" + LIKERT = "likert", "Likert Scale (1-5)" class SurveyTemplate(UUIDModel, TimeStampedModel): @@ -48,108 +52,93 @@ class SurveyTemplate(UUIDModel, TimeStampedModel): - Multiple question types - Scoring configuration - Branch logic (conditional questions) + + NOTE: Supports both template-based and event-based surveys. + Event-based surveys use SurveyTemplate questions filtered by HIS events. """ + name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") # Configuration - hospital = models.ForeignKey( - 'organizations.Hospital', - on_delete=models.CASCADE, - related_name='survey_templates' - ) + hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="survey_templates") # Survey type survey_type = models.CharField( max_length=50, choices=[ - ('stage', 'Journey Stage Survey'), - ('complaint_resolution', 'Complaint Resolution Satisfaction'), - ('general', 'General Feedback'), - ('nps', 'Net Promoter Score'), + ("stage", "Journey Stage Survey"), + ("complaint_resolution", "Complaint Resolution Satisfaction"), + ("general", "General Feedback"), + ("nps", "Net Promoter Score"), ], - default='stage', - db_index=True + default="stage", + db_index=True, ) # Scoring configuration scoring_method = models.CharField( max_length=20, choices=[ - ('average', 'Average Score'), - ('weighted', 'Weighted Average'), - ('nps', 'NPS Calculation'), + ("average", "Average Score"), + ("weighted", "Weighted Average"), + ("nps", "NPS Calculation"), ], - default='average' + default="average", ) negative_threshold = models.DecimalField( - max_digits=3, - decimal_places=1, - default=3.0, - help_text="Scores below this trigger PX actions (out of 5)" + max_digits=3, decimal_places=1, default=3.0, help_text="Scores below this trigger PX actions (out of 5)" ) # Configuration is_active = models.BooleanField(default=True, db_index=True) class Meta: - ordering = ['hospital', 'name'] + ordering = ["hospital", "name"] indexes = [ - models.Index(fields=['hospital', 'survey_type', 'is_active']), + models.Index(fields=["hospital", "survey_type", "is_active"]), ] def __str__(self): return self.name def get_question_count(self): - """Get number of questions""" return self.questions.count() class SurveyQuestion(UUIDModel, TimeStampedModel): """ - Survey question within a template. + Survey question belonging to a template. - Supports: - - Bilingual text (AR/EN) - - Multiple question types - - Required/optional - - Conditional display (branch logic) + Questions can be: + - Base questions (is_base=True): Always included in surveys from this template + - Event-based questions (event_type set): Only included when the patient + experienced that specific event type in their HIS visit """ - survey_template = models.ForeignKey( - SurveyTemplate, - on_delete=models.CASCADE, - related_name='questions' - ) - # Question text + survey_template = models.ForeignKey(SurveyTemplate, on_delete=models.CASCADE, related_name="questions") + text = models.TextField(verbose_name="Question Text (English)") text_ar = models.TextField(blank=True, verbose_name="Question Text (Arabic)") - - # Question configuration - question_type = models.CharField( - max_length=20, - choices=QuestionType.choices, - default=QuestionType.RATING - ) + question_type = models.CharField(max_length=20, choices=QuestionType.choices, default="rating") order = models.IntegerField(default=0, help_text="Display order") is_required = models.BooleanField(default=True) + choices_json = models.JSONField(blank=True, default=list, help_text="Array of choice objects") - # For multiple choice questions - choices_json = models.JSONField( - default=list, + is_base = models.BooleanField( + default=False, + db_index=True, + help_text="Always include this question regardless of events", + ) + event_type = models.CharField( + max_length=200, blank=True, - help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]" + db_index=True, + help_text="HIS event type that triggers this question (e.g., 'Lab Bill', 'Triage')", ) class Meta: - ordering = ['survey_template', 'order'] - indexes = [ - models.Index(fields=['survey_template', 'order']), - ] - - def __str__(self): - return f"{self.survey_template.name} - Q{self.order}: {self.text[:50]}" + ordering = ["survey_template", "order"] class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): @@ -165,38 +154,27 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): Tenant-aware: All surveys are scoped to a hospital. """ - survey_template = models.ForeignKey( - SurveyTemplate, - on_delete=models.PROTECT, - related_name='instances' - ) + + survey_template = models.ForeignKey(SurveyTemplate, on_delete=models.PROTECT, related_name="instances") # Patient information patient = models.ForeignKey( - 'organizations.Patient', - on_delete=models.CASCADE, - related_name='surveys', - null=True, - blank=True + "organizations.Patient", on_delete=models.CASCADE, related_name="surveys", null=True, blank=True ) - + # Staff information staff = models.ForeignKey( - 'organizations.Staff', + "organizations.Staff", on_delete=models.CASCADE, - related_name='surveys', + related_name="surveys", null=True, blank=True, - help_text="Staff recipient (if survey is for staff)" + help_text="Staff recipient (if survey is for staff)", ) # Journey linkage journey_instance = models.ForeignKey( - 'journeys.PatientJourneyInstance', - on_delete=models.CASCADE, - null=True, - blank=True, - related_name='surveys' + "journeys.PatientJourneyInstance", on_delete=models.CASCADE, null=True, blank=True, related_name="surveys" ) encounter_id = models.CharField(max_length=100, blank=True, db_index=True) @@ -204,154 +182,102 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): delivery_channel = models.CharField( max_length=20, choices=[ - ('sms', 'SMS'), - ('whatsapp', 'WhatsApp'), - ('email', 'Email'), + ("sms", "SMS"), + ("whatsapp", "WhatsApp"), + ("email", "Email"), ], - default='sms' + default="sms", ) recipient_phone = models.CharField(max_length=20, blank=True) recipient_email = models.EmailField(blank=True) # Access token for secure link access_token = models.CharField( - max_length=100, - unique=True, - db_index=True, - blank=True, - help_text="Secure token for survey access" - ) - token_expires_at = models.DateTimeField( - null=True, - blank=True, - help_text="Token expiration date" + max_length=100, unique=True, db_index=True, blank=True, help_text="Secure token for survey access" ) + token_expires_at = models.DateTimeField(null=True, blank=True, help_text="Token expiration date") # Status - status = models.CharField( - max_length=20, - choices=SurveyStatus.choices, - default=SurveyStatus.SENT, - db_index=True - ) + status = models.CharField(max_length=20, choices=SurveyStatus.choices, default=SurveyStatus.SENT, db_index=True) # Timestamps sent_at = models.DateTimeField(null=True, blank=True, db_index=True) scheduled_send_at = models.DateTimeField( - null=True, - blank=True, - db_index=True, - help_text="When this survey should be sent (for delayed sending)" + null=True, blank=True, db_index=True, help_text="When this survey should be sent (for delayed sending)" ) opened_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) - + # Enhanced tracking - open_count = models.IntegerField( - default=0, - help_text="Number of times survey link was opened" - ) - last_opened_at = models.DateTimeField( - null=True, - blank=True, - help_text="Most recent time survey was opened" - ) - time_spent_seconds = models.IntegerField( - null=True, - blank=True, - help_text="Total time spent on survey in seconds" - ) + open_count = models.IntegerField(default=0, help_text="Number of times survey link was opened") + last_opened_at = models.DateTimeField(null=True, blank=True, help_text="Most recent time survey was opened") + time_spent_seconds = models.IntegerField(null=True, blank=True, help_text="Total time spent on survey in seconds") # Scoring total_score = models.DecimalField( - max_digits=5, - decimal_places=2, - null=True, - blank=True, - help_text="Calculated total score" - ) - is_negative = models.BooleanField( - default=False, - db_index=True, - help_text="True if score below threshold" + max_digits=5, decimal_places=2, null=True, blank=True, help_text="Calculated total score" ) + is_negative = models.BooleanField(default=False, db_index=True, help_text="True if score below threshold") # Metadata metadata = models.JSONField(default=dict, blank=True) # Patient contact tracking (for negative surveys) patient_contacted = models.BooleanField( - default=False, - help_text="Whether patient was contacted about negative survey" + default=False, help_text="Whether patient was contacted about negative survey" ) patient_contacted_at = models.DateTimeField(null=True, blank=True) patient_contacted_by = models.ForeignKey( - 'accounts.User', + "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, - related_name='contacted_surveys', - help_text="User who contacted the patient" - ) - contact_notes = models.TextField( - blank=True, - help_text="Notes from patient contact" - ) - issue_resolved = models.BooleanField( - default=False, - help_text="Whether the issue was resolved/explained" + related_name="contacted_surveys", + help_text="User who contacted the patient", ) + contact_notes = models.TextField(blank=True, help_text="Notes from patient contact") + issue_resolved = models.BooleanField(default=False, help_text="Whether the issue was resolved/explained") # Patient comment and AI analysis - comment = models.TextField( - blank=True, - help_text="Optional comment from patient" - ) - comment_analyzed = models.BooleanField( - default=False, - help_text="Whether the comment has been analyzed by AI" - ) - comment_analysis = models.JSONField( - default=dict, - blank=True, - help_text="AI analysis results for the comment" - ) + comment = models.TextField(blank=True, help_text="Optional comment from patient") + comment_analyzed = models.BooleanField(default=False, help_text="Whether the comment has been analyzed by AI") + comment_analysis = models.JSONField(default=dict, blank=True, help_text="AI analysis results for the comment") class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] indexes = [ - models.Index(fields=['patient', '-created_at']), - models.Index(fields=['staff', '-created_at']), - models.Index(fields=['status', '-sent_at']), - models.Index(fields=['is_negative', '-completed_at']), + models.Index(fields=["patient", "-created_at"]), + models.Index(fields=["staff", "-created_at"]), + models.Index(fields=["status", "-sent_at"]), + models.Index(fields=["is_negative", "-completed_at"]), ] def __str__(self): - recipient = self.get_recipient_name() - return f"{self.survey_template.name} - {recipient}" - + return f"{self.survey_template.name} - {self.get_recipient_name()}" + def clean(self): """Validate that exactly one of patient or staff is set""" from django.core.exceptions import ValidationError + if self.patient and self.staff: raise ValidationError("Cannot specify both patient and staff for a survey") if not self.patient and not self.staff: raise ValidationError("Must specify either a patient or staff recipient") - + def get_recipient(self): """Get the recipient object (patient or staff)""" return self.patient if self.patient else self.staff - + def get_recipient_name(self): """Get recipient name (patient or staff)""" if self.patient: return self.patient.get_full_name() elif self.staff: return self.staff.get_full_name() - elif self.metadata and self.metadata.get('recipient_name'): - return self.metadata.get('recipient_name') + elif self.metadata and self.metadata.get("recipient_name"): + return self.metadata.get("recipient_name") return "Unknown" - + def get_recipient_email(self): """Get recipient email (patient or staff)""" if self.patient: @@ -359,7 +285,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): elif self.staff: return self.staff.email return None - + def get_recipient_phone(self): """Get recipient phone (patient or staff)""" if self.patient: @@ -378,7 +304,8 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): from datetime import timedelta from django.conf import settings from django.utils import timezone - days = getattr(settings, 'SURVEY_TOKEN_EXPIRY_DAYS', 30) + + days = getattr(settings, "SURVEY_TOKEN_EXPIRY_DAYS", 30) self.token_expires_at = timezone.now() + timedelta(days=days) super().save(*args, **kwargs) @@ -390,30 +317,15 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): def calculate_score(self): """ Calculate total score from responses. - - Returns the calculated score and updates the instance. """ responses = self.responses.all() if not responses.exists(): return None - if self.survey_template.scoring_method == 'average': - # Simple average of all rating responses - rating_responses = responses.filter( - question__question_type__in=['rating', 'likert', 'nps'] - ) - if rating_responses.exists(): - total = sum(float(r.numeric_value or 0) for r in rating_responses) - count = rating_responses.count() - score = total / count if count > 0 else 0 - else: - score = 0 + scoring_method = self.survey_template.scoring_method - elif self.survey_template.scoring_method == 'weighted': - # Simple average (weight feature removed) - rating_responses = responses.filter( - question__question_type__in=['rating', 'likert', 'nps'] - ) + if scoring_method in ("average", "weighted"): + rating_responses = responses.filter(question__question_type__in=["rating", "likert", "nps"]) if rating_responses.exists(): total = sum(float(r.numeric_value or 0) for r in rating_responses) count = rating_responses.count() @@ -422,8 +334,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): score = 0 else: # NPS - # NPS calculation: % promoters - % detractors - nps_responses = responses.filter(question__question_type='nps') + nps_responses = responses.filter(question__question_type="nps") if nps_responses.exists(): promoters = nps_responses.filter(numeric_value__gte=9).count() detractors = nps_responses.filter(numeric_value__lte=6).count() @@ -432,10 +343,9 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): else: score = 0 - # Update instance self.total_score = score self.is_negative = score < float(self.survey_template.negative_threshold) - self.save(update_fields=['total_score', 'is_negative']) + self.save(update_fields=["total_score", "is_negative"]) return score @@ -444,241 +354,173 @@ class SurveyResponse(UUIDModel, TimeStampedModel): """ Survey response - answer to a specific question. """ - survey_instance = models.ForeignKey( - SurveyInstance, - on_delete=models.CASCADE, - related_name='responses' - ) - question = models.ForeignKey( - SurveyQuestion, - on_delete=models.PROTECT, - related_name='responses' - ) + + survey_instance = models.ForeignKey(SurveyInstance, on_delete=models.CASCADE, related_name="responses") + question = models.ForeignKey(SurveyQuestion, on_delete=models.PROTECT, related_name="responses") # Response value (type depends on question type) numeric_value = models.DecimalField( - max_digits=10, - decimal_places=2, - null=True, - blank=True, - help_text="For rating, NPS, Likert questions" - ) - text_value = models.TextField( - blank=True, - help_text="For text, textarea questions" - ) - choice_value = models.CharField( - max_length=200, - blank=True, - help_text="For multiple choice questions" + max_digits=10, decimal_places=2, null=True, blank=True, help_text="For rating, NPS, Likert questions" ) + text_value = models.TextField(blank=True, help_text="For text, textarea questions") + choice_value = models.CharField(max_length=200, blank=True, help_text="For multiple choice questions") class Meta: - ordering = ['survey_instance', 'question__order'] - unique_together = [['survey_instance', 'question']] + ordering = ["survey_instance", "question__order"] + unique_together = [["survey_instance", "question"]] + class SurveyTracking(UUIDModel, TimeStampedModel): """ Detailed survey engagement tracking. - + Tracks multiple interactions with survey: - Page views - Time spent on survey - Abandonment events - Device/browser information """ - survey_instance = models.ForeignKey( - SurveyInstance, - on_delete=models.CASCADE, - related_name='tracking_events' - ) - + + survey_instance = models.ForeignKey(SurveyInstance, on_delete=models.CASCADE, related_name="tracking_events") + # Event type event_type = models.CharField( max_length=50, choices=[ - ('page_view', 'Page View'), - ('survey_started', 'Survey Started'), - ('question_answered', 'Question Answered'), - ('survey_abandoned', 'Survey Abandoned'), - ('survey_completed', 'Survey Completed'), - ('reminder_sent', 'Reminder Sent'), + ("page_view", "Page View"), + ("survey_started", "Survey Started"), + ("question_answered", "Question Answered"), + ("survey_abandoned", "Survey Abandoned"), + ("survey_completed", "Survey Completed"), + ("reminder_sent", "Reminder Sent"), ], - db_index=True + db_index=True, ) - + # Timing - time_on_page = models.IntegerField( - null=True, - blank=True, - help_text="Time spent on page in seconds" - ) + time_on_page = models.IntegerField(null=True, blank=True, help_text="Time spent on page in seconds") total_time_spent = models.IntegerField( - null=True, - blank=True, - help_text="Total time spent on survey so far in seconds" + null=True, blank=True, help_text="Total time spent on survey so far in seconds" ) - + # Context - current_question = models.IntegerField( - null=True, - blank=True, - help_text="Question number when event occurred" - ) - + current_question = models.IntegerField(null=True, blank=True, help_text="Question number when event occurred") + # Device info user_agent = models.TextField(blank=True) ip_address = models.GenericIPAddressField(null=True, blank=True) - device_type = models.CharField( - max_length=50, - blank=True, - help_text="mobile, tablet, desktop" - ) - browser = models.CharField( - max_length=100, - blank=True - ) - + device_type = models.CharField(max_length=50, blank=True, help_text="mobile, tablet, desktop") + browser = models.CharField(max_length=100, blank=True) + # Location (optional, for analytics) country = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True) - + # Metadata metadata = models.JSONField(default=dict, blank=True) - + class Meta: - ordering = ['survey_instance', 'created_at'] + ordering = ["survey_instance", "created_at"] indexes = [ - models.Index(fields=['survey_instance', 'event_type', '-created_at']), - models.Index(fields=['event_type', '-created_at']), + models.Index(fields=["survey_instance", "event_type", "-created_at"]), + models.Index(fields=["event_type", "-created_at"]), ] - + def __str__(self): return f"{self.survey_instance.id} - {self.event_type} at {self.created_at}" - + @classmethod def track_event(cls, survey_instance, event_type, **kwargs): """ Helper method to track a survey event. - + Args: survey_instance: SurveyInstance event_type: str - event type key **kwargs: additional fields (time_on_page, current_question, etc.) - + Returns: SurveyTracking instance """ - return cls.objects.create( - survey_instance=survey_instance, - event_type=event_type, - **kwargs - ) + return cls.objects.create(survey_instance=survey_instance, event_type=event_type, **kwargs) class BulkSurveyJob(UUIDModel, TimeStampedModel): """ Tracks bulk survey sending jobs for background processing. - + Used for HIS import and other bulk survey operations. """ + class JobStatus(models.TextChoices): - PENDING = 'pending', 'Pending' - PROCESSING = 'processing', 'Processing' - COMPLETED = 'completed', 'Completed' - FAILED = 'failed', 'Failed' - PARTIAL = 'partial', 'Partially Completed' - + PENDING = "pending", "Pending" + PROCESSING = "processing", "Processing" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + PARTIAL = "partial", "Partially Completed" + class JobSource(models.TextChoices): - HIS_IMPORT = 'his_import', 'HIS Import' - CSV_UPLOAD = 'csv_upload', 'CSV Upload' - MANUAL = 'manual', 'Manual' - + HIS_IMPORT = "his_import", "HIS Import" + CSV_UPLOAD = "csv_upload", "CSV Upload" + MANUAL = "manual", "Manual" + # Job info name = models.CharField(max_length=200, blank=True) - status = models.CharField( - max_length=20, - choices=JobStatus.choices, - default=JobStatus.PENDING, - db_index=True - ) - source = models.CharField( - max_length=20, - choices=JobSource.choices, - default=JobSource.MANUAL - ) - + status = models.CharField(max_length=20, choices=JobStatus.choices, default=JobStatus.PENDING, db_index=True) + source = models.CharField(max_length=20, choices=JobSource.choices, default=JobSource.MANUAL) + # User who initiated created_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - related_name='bulk_survey_jobs' + "accounts.User", on_delete=models.SET_NULL, null=True, related_name="bulk_survey_jobs" ) - + # Hospital - hospital = models.ForeignKey( - 'organizations.Hospital', - on_delete=models.CASCADE, - related_name='bulk_survey_jobs' - ) - + hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="bulk_survey_jobs") + # Survey template used - survey_template = models.ForeignKey( - SurveyTemplate, - on_delete=models.SET_NULL, - null=True, - related_name='bulk_jobs' - ) - + survey_template = models.ForeignKey(SurveyTemplate, on_delete=models.SET_NULL, null=True, related_name="bulk_jobs") + # Progress tracking total_patients = models.IntegerField(default=0) processed_count = models.IntegerField(default=0) success_count = models.IntegerField(default=0) failed_count = models.IntegerField(default=0) - + # Delivery settings - delivery_channel = models.CharField(max_length=20, default='sms') + delivery_channel = models.CharField(max_length=20, default="sms") custom_message = models.TextField(blank=True) - + # Patient data (stored as JSON list) - patient_data = models.JSONField( - default=list, - help_text="List of patient IDs and file numbers to process" - ) - + patient_data = models.JSONField(default=list, help_text="List of patient IDs and file numbers to process") + # Results - results = models.JSONField( - default=dict, - blank=True, - help_text="Detailed results including successes and failures" - ) - + results = models.JSONField(default=dict, blank=True, help_text="Detailed results including successes and failures") + # Error info error_message = models.TextField(blank=True) - + # Timestamps started_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) - + class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] indexes = [ - models.Index(fields=['status', '-created_at']), - models.Index(fields=['created_by', '-created_at']), - models.Index(fields=['hospital', '-created_at']), + models.Index(fields=["status", "-created_at"]), + models.Index(fields=["created_by", "-created_at"]), + models.Index(fields=["hospital", "-created_at"]), ] - + def __str__(self): return f"Bulk Survey Job {self.id[:8]} - {self.status}" - + @property def progress_percentage(self): """Calculate progress percentage""" if self.total_patients == 0: return 0 return int((self.processed_count / self.total_patients) * 100) - + @property def is_complete(self): """Check if job is complete""" diff --git a/apps/surveys/public_views.py b/apps/surveys/public_views.py index 4e7a117..3afb3ee 100644 --- a/apps/surveys/public_views.py +++ b/apps/surveys/public_views.py @@ -1,7 +1,9 @@ """ Public survey views - Token-based survey forms (no login required) """ + from django.contrib import messages +from django.db.models import Q from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone @@ -19,7 +21,7 @@ from .analytics import track_survey_open, track_survey_completion def survey_form(request, token): """ Public survey form - accessible via secure token link. - + Features: - No login required - Token-based access @@ -32,293 +34,291 @@ def survey_form(request, token): # Get survey instance by token # Allow access until survey is completed or token expires (2 days by default) try: - survey = SurveyInstance.objects.select_related( - 'survey_template', - 'patient', - 'journey_instance' - ).prefetch_related( - 'survey_template__questions' - ).get( - access_token=token, - status__in=['pending', 'sent', 'viewed', 'in_progress'], - token_expires_at__gt=timezone.now() + survey = ( + SurveyInstance.objects.select_related("survey_template", "patient", "journey_instance") + .prefetch_related("survey_template__questions") + .get( + access_token=token, + status__in=["pending", "sent", "viewed", "in_progress"], + token_expires_at__gt=timezone.now(), + ) ) except SurveyInstance.DoesNotExist: - return render(request, 'surveys/invalid_token.html', { - 'error': 'invalid_or_expired' - }) - + return render(request, "surveys/invalid_token.html", {"error": "invalid_or_expired"}) + # Track survey open - increment count and record tracking event # Get device info from user agent - user_agent_str = request.META.get('HTTP_USER_AGENT', '') - ip_address = request.META.get('REMOTE_ADDR', '') - + user_agent_str = request.META.get("HTTP_USER_AGENT", "") + ip_address = request.META.get("REMOTE_ADDR", "") + # Parse user agent for device info user_agent = parse(user_agent_str) - device_type = 'mobile' if user_agent.is_mobile else ('tablet' if user_agent.is_tablet else 'desktop') + device_type = "mobile" if user_agent.is_mobile else ("tablet" if user_agent.is_tablet else "desktop") browser = f"{user_agent.browser.family} {user_agent.browser.version_string}" - + # Update survey instance tracking fields survey.open_count += 1 survey.last_opened_at = timezone.now() - + # Update status based on current state if not survey.opened_at: survey.opened_at = timezone.now() - survey.status = 'viewed' - elif survey.status == 'sent': - survey.status = 'viewed' - - survey.save(update_fields=['open_count', 'last_opened_at', 'opened_at', 'status']) - + survey.status = "viewed" + elif survey.status == "sent": + survey.status = "viewed" + + survey.save(update_fields=["open_count", "last_opened_at", "opened_at", "status"]) + # Track page view event SurveyTracking.track_event( survey, - 'page_view', - user_agent=user_agent_str[:500] if user_agent_str else '', + "page_view", + user_agent=user_agent_str[:500] if user_agent_str else "", ip_address=ip_address, device_type=device_type, browser=browser, metadata={ - 'referrer': request.META.get('HTTP_REFERER', ''), - 'language': request.GET.get('lang', 'en'), - } + "referrer": request.META.get("HTTP_REFERER", ""), + "language": request.GET.get("lang", "en"), + }, ) - - # Get questions - questions = survey.survey_template.questions.filter( - is_required=True - ).order_by('order') | survey.survey_template.questions.filter( - is_required=False - ).order_by('order') - - if request.method == 'POST': + + # Get questions — filter by patient's experienced events + patient_events = set(survey.metadata.get("event_types", [])) + questions = survey.survey_template.questions.filter(Q(is_base=True) | Q(event_type__in=patient_events)).order_by( + "order" + ) + + if request.method == "POST": # Process survey responses - language = request.POST.get('language', 'en') + language = request.POST.get("language", "en") errors = [] responses_data = [] - + # Validate and collect responses for question in questions: - field_name = f'question_{question.id}' - + field_name = f"question_{question.id}" + # Check if required if question.is_required and not request.POST.get(field_name): errors.append(f"Question {question.order + 1} is required") continue - + # Get response value based on question type - if question.question_type in ['rating', 'likert']: + if question.question_type in ["rating", "likert"]: numeric_value = request.POST.get(field_name) if numeric_value: - responses_data.append({ - 'question': question, - 'numeric_value': float(numeric_value), - 'text_value': '', - 'choice_value': '' - }) - - elif question.question_type == 'nps': + responses_data.append( + { + "question": question, + "numeric_value": float(numeric_value), + "text_value": "", + "choice_value": "", + } + ) + + elif question.question_type == "nps": numeric_value = request.POST.get(field_name) if numeric_value: - responses_data.append({ - 'question': question, - 'numeric_value': float(numeric_value), - 'text_value': '', - 'choice_value': '' - }) - - elif question.question_type == 'yes_no': + responses_data.append( + { + "question": question, + "numeric_value": float(numeric_value), + "text_value": "", + "choice_value": "", + } + ) + + elif question.question_type == "yes_no": choice_value = request.POST.get(field_name) if choice_value: # Convert yes/no to numeric for scoring - numeric_value = 5.0 if choice_value == 'yes' else 1.0 - responses_data.append({ - 'question': question, - 'numeric_value': numeric_value, - 'text_value': '', - 'choice_value': choice_value - }) - - elif question.question_type == 'multiple_choice': + numeric_value = 5.0 if choice_value == "yes" else 1.0 + responses_data.append( + { + "question": question, + "numeric_value": numeric_value, + "text_value": "", + "choice_value": choice_value, + } + ) + + elif question.question_type == "multiple_choice": choice_value = request.POST.get(field_name) if choice_value: # Find the selected choice to get its label selected_choice = None for choice in question.choices_json: - if str(choice.get('value', '')) == str(choice_value): + if str(choice.get("value", "")) == str(choice_value): selected_choice = choice break - + # Get the label based on language - language = request.POST.get('language', 'en') - if language == 'ar' and selected_choice and selected_choice.get('label_ar'): - text_value = selected_choice['label_ar'] - elif selected_choice and selected_choice.get('label'): - text_value = selected_choice['label'] + language = request.POST.get("language", "en") + if language == "ar" and selected_choice and selected_choice.get("label_ar"): + text_value = selected_choice["label_ar"] + elif selected_choice and selected_choice.get("label"): + text_value = selected_choice["label"] else: text_value = choice_value - + # Try to convert choice value to numeric for scoring try: numeric_value = float(choice_value) except (ValueError, TypeError): numeric_value = None - - responses_data.append({ - 'question': question, - 'numeric_value': numeric_value, - 'text_value': text_value, - 'choice_value': choice_value - }) - - elif question.question_type in ['text', 'textarea']: - text_value = request.POST.get(field_name, '') + + responses_data.append( + { + "question": question, + "numeric_value": numeric_value, + "text_value": text_value, + "choice_value": choice_value, + } + ) + + elif question.question_type in ["text", "textarea"]: + text_value = request.POST.get(field_name, "") if text_value: - responses_data.append({ - 'question': question, - 'numeric_value': None, - 'text_value': text_value, - 'choice_value': '' - }) - + responses_data.append( + {"question": question, "numeric_value": None, "text_value": text_value, "choice_value": ""} + ) + # Get optional comment - comment = request.POST.get('comment', '').strip() - + comment = request.POST.get("comment", "").strip() + # If validation errors, show form again if errors: context = { - 'survey': survey, - 'questions': questions, - 'errors': errors, - 'language': language, + "survey": survey, + "questions": questions, + "errors": errors, + "language": language, } - return render(request, 'surveys/public_form.html', context) - + return render(request, "surveys/public_form.html", context) + # Save responses for response_data in responses_data: SurveyResponse.objects.update_or_create( survey_instance=survey, - question=response_data['question'], + question=response_data["question"], defaults={ - 'numeric_value': response_data['numeric_value'], - 'text_value': response_data['text_value'], - 'choice_value': response_data['choice_value'], - } + "numeric_value": response_data["numeric_value"], + "text_value": response_data["text_value"], + "choice_value": response_data["choice_value"], + }, ) - + # Update survey status - survey.status = 'completed' + survey.status = "completed" survey.completed_at = timezone.now() - + # Calculate time spent (from opened_at to completed_at) if survey.opened_at: time_spent = (timezone.now() - survey.opened_at).total_seconds() survey.time_spent_seconds = int(time_spent) - - survey.save(update_fields=['status', 'completed_at', 'time_spent_seconds']) - + + survey.save(update_fields=["status", "completed_at", "time_spent_seconds"]) + # Track completion event SurveyTracking.track_event( survey, - 'survey_completed', + "survey_completed", total_time_spent=survey.time_spent_seconds, - user_agent=user_agent_str[:500] if user_agent_str else '', + user_agent=user_agent_str[:500] if user_agent_str else "", ip_address=ip_address, metadata={ - 'response_count': len(responses_data), - 'language': language, - } + "response_count": len(responses_data), + "language": language, + }, ) - + # Calculate score score = survey.calculate_score() - + # Log completion AuditService.log_event( - event_type='survey_completed', + event_type="survey_completed", description=f"Survey completed: {survey.survey_template.name}", user=None, content_object=survey, metadata={ - 'score': float(score) if score else None, - 'is_negative': survey.is_negative, - 'response_count': len(responses_data), - 'has_comment': bool(comment) - } + "score": float(score) if score else None, + "is_negative": survey.is_negative, + "response_count": len(responses_data), + "has_comment": bool(comment), + }, ) - + # Save comment and trigger AI analysis if present if comment: survey.comment = comment - survey.save(update_fields=['comment']) - + survey.save(update_fields=["comment"]) + # Trigger background task for comment analysis from apps.surveys.tasks import analyze_survey_comment + try: analyze_survey_comment.delay(str(survey.id)) except Exception as e: # Log but don't fail the survey submission import logging + logger = logging.getLogger(__name__) logger.error(f"Failed to trigger comment analysis: {str(e)}") - + # Create PX action if negative if survey.is_negative: from apps.surveys.tasks import create_action_from_negative_survey + try: create_action_from_negative_survey.delay(str(survey.id)) except Exception as e: # Log but don't fail the survey submission import logging + logger = logging.getLogger(__name__) logger.error(f"Failed to trigger action creation: {str(e)}") - + # Redirect to thank you page - return redirect('surveys:thank_you', token=token) - + return redirect("surveys:thank_you", token=token) + # GET request - show form # Determine language from query param or browser - language = request.GET.get('lang', 'en') - + language = request.GET.get("lang", "en") + context = { - 'survey': survey, - 'questions': questions, - 'language': language, - 'total_questions': questions.count(), + "survey": survey, + "questions": questions, + "language": language, + "total_questions": questions.count(), } - - return render(request, 'surveys/public_form.html', context) + + return render(request, "surveys/public_form.html", context) def thank_you(request, token): """Thank you page after survey completion""" try: - survey = SurveyInstance.objects.select_related( - 'survey_template', - 'patient' - ).get( - access_token=token, - status='completed' + survey = SurveyInstance.objects.select_related("survey_template", "patient").get( + access_token=token, status="completed" ) except SurveyInstance.DoesNotExist: - return render(request, 'surveys/invalid_token.html', { - 'error': 'not_found' - }) - - language = request.GET.get('lang', 'en') - + return render(request, "surveys/invalid_token.html", {"error": "not_found"}) + + language = request.GET.get("lang", "en") + context = { - 'survey': survey, - 'language': language, + "survey": survey, + "language": language, } - - return render(request, 'surveys/thank_you.html', context) + + return render(request, "surveys/thank_you.html", context) def invalid_token(request): """Invalid or expired token page""" - return render(request, 'surveys/invalid_token.html') + return render(request, "surveys/invalid_token.html") @csrf_exempt @@ -326,41 +326,38 @@ def invalid_token(request): def track_survey_start(request, token): """ API endpoint to track when patient starts answering survey. - + Called via AJAX when patient first interacts with the form. Updates status from 'viewed' to 'in_progress'. """ try: # Get survey instance survey = SurveyInstance.objects.get( - access_token=token, - status__in=['viewed', 'in_progress'], - token_expires_at__gt=timezone.now() + access_token=token, status__in=["viewed", "in_progress"], token_expires_at__gt=timezone.now() ) - + # Only update if not already in_progress - if survey.status == 'viewed': - survey.status = 'in_progress' - survey.save(update_fields=['status']) - + if survey.status == "viewed": + survey.status = "in_progress" + survey.save(update_fields=["status"]) + # Track survey started event SurveyTracking.track_event( survey, - 'survey_started', - user_agent=request.META.get('HTTP_USER_AGENT', '')[:500] if request.META.get('HTTP_USER_AGENT') else '', - ip_address=request.META.get('REMOTE_ADDR', ''), + "survey_started", + user_agent=request.META.get("HTTP_USER_AGENT", "")[:500] if request.META.get("HTTP_USER_AGENT") else "", + ip_address=request.META.get("REMOTE_ADDR", ""), metadata={ - 'referrer': request.META.get('HTTP_REFERER', ''), - } + "referrer": request.META.get("HTTP_REFERER", ""), + }, ) - - return JsonResponse({ - 'status': 'success', - 'survey_status': survey.status, - }) - + + return JsonResponse( + { + "status": "success", + "survey_status": survey.status, + } + ) + except SurveyInstance.DoesNotExist: - return JsonResponse({ - 'status': 'error', - 'message': 'Survey not found or invalid token' - }, status=404) + return JsonResponse({"status": "error", "message": "Survey not found or invalid token"}, status=404) diff --git a/apps/surveys/serializers.py b/apps/surveys/serializers.py index 8041070..e085fc1 100644 --- a/apps/surveys/serializers.py +++ b/apps/surveys/serializers.py @@ -1,43 +1,66 @@ """ Surveys serializers """ + from rest_framework import serializers -from .models import SurveyInstance, SurveyQuestion, SurveyResponse, SurveyTemplate, SurveyTracking +from .models import ( + SurveyInstance, + SurveyQuestion, + SurveyResponse, + SurveyTemplate, + SurveyTracking, +) class SurveyQuestionSerializer(serializers.ModelSerializer): """Survey question serializer""" - + class Meta: model = SurveyQuestion fields = [ - 'id', 'survey_template', 'text', 'text_ar', - 'question_type', 'order', 'is_required', - 'choices_json', - 'created_at', 'updated_at' + "id", + "survey_template", + "text", + "text_ar", + "question_type", + "order", + "is_required", + "is_base", + "event_type", + "choices_json", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_at", "updated_at"] class SurveyTemplateSerializer(serializers.ModelSerializer): """Survey template serializer""" - hospital_name = serializers.CharField(source='hospital.name', read_only=True) + + hospital_name = serializers.CharField(source="hospital.name", read_only=True) questions = SurveyQuestionSerializer(many=True, read_only=True) question_count = serializers.SerializerMethodField() - + class Meta: model = SurveyTemplate fields = [ - 'id', 'name', 'name_ar', - 'hospital', 'hospital_name', 'survey_type', - 'scoring_method', 'negative_threshold', - 'is_active', - 'questions', 'question_count', - 'created_at', 'updated_at' + "id", + "name", + "name_ar", + "hospital", + "hospital_name", + "survey_type", + "scoring_method", + "negative_threshold", + "is_active", + "questions", + "question_count", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] - + read_only_fields = ["id", "created_at", "updated_at"] + def get_question_count(self, obj): """Get number of questions""" return obj.get_question_count() @@ -45,79 +68,132 @@ class SurveyTemplateSerializer(serializers.ModelSerializer): class SurveyResponseSerializer(serializers.ModelSerializer): """Survey response serializer""" - question_text = serializers.CharField(source='question.text', read_only=True) - question_type = serializers.CharField(source='question.question_type', read_only=True) - + + question_text = serializers.SerializerMethodField() + question_type = serializers.SerializerMethodField() + class Meta: model = SurveyResponse fields = [ - 'id', 'survey_instance', 'question', 'question_text', 'question_type', - 'numeric_value', 'text_value', 'choice_value', - 'response_time_seconds', - 'created_at', 'updated_at' + "id", + "survey_instance", + "question", + "question_text", + "question_type", + "numeric_value", + "text_value", + "choice_value", + "response_time_seconds", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_question_text(self, obj): + if obj.question: + return obj.question.text + return "" + + def get_question_type(self, obj): + if obj.question: + return obj.question.question_type + return "" class SurveyInstanceSerializer(serializers.ModelSerializer): """Survey instance serializer""" - survey_template_name = serializers.CharField(source='survey_template.name', read_only=True) - recipient_name = serializers.CharField(source='get_recipient_name', read_only=True) - patient_name = serializers.CharField(source='patient.get_full_name', read_only=True) - patient_mrn = serializers.CharField(source='patient.mrn', read_only=True) - staff_name = serializers.CharField(source='staff.get_full_name', read_only=True) - staff_email = serializers.EmailField(source='staff.email', read_only=True) + + survey_template_name = serializers.SerializerMethodField() + recipient_name = serializers.CharField(source="get_recipient_name", read_only=True) + patient_name = serializers.CharField(source="patient.get_full_name", read_only=True) + patient_mrn = serializers.CharField(source="patient.mrn", read_only=True) + staff_name = serializers.CharField(source="staff.get_full_name", read_only=True) + staff_email = serializers.EmailField(source="staff.email", read_only=True) responses = SurveyResponseSerializer(many=True, read_only=True) survey_url = serializers.SerializerMethodField() comment_analysis = serializers.JSONField(read_only=True) - + class Meta: model = SurveyInstance fields = [ - 'id', 'survey_template', 'survey_template_name', - 'patient', 'patient_name', 'patient_mrn', - 'staff', 'staff_name', 'staff_email', - 'recipient_name', - 'journey_instance', 'encounter_id', - 'delivery_channel', 'recipient_phone', 'recipient_email', - 'access_token', 'token_expires_at', 'survey_url', - 'status', 'sent_at', 'opened_at', 'completed_at', - 'total_score', 'is_negative', - 'comment', 'comment_analyzed', 'comment_analysis', - 'responses', 'metadata', - 'created_at', 'updated_at' + "id", + "survey_template", + "survey_template_name", + "patient", + "patient_name", + "patient_mrn", + "staff", + "staff_name", + "staff_email", + "recipient_name", + "journey_instance", + "encounter_id", + "delivery_channel", + "recipient_phone", + "recipient_email", + "access_token", + "token_expires_at", + "survey_url", + "status", + "sent_at", + "opened_at", + "completed_at", + "total_score", + "is_negative", + "comment", + "comment_analyzed", + "comment_analysis", + "responses", + "metadata", + "created_at", + "updated_at", ] read_only_fields = [ - 'id', 'access_token', 'token_expires_at', - 'sent_at', 'opened_at', 'completed_at', - 'total_score', 'is_negative', - 'created_at', 'updated_at' + "id", + "access_token", + "token_expires_at", + "sent_at", + "opened_at", + "completed_at", + "total_score", + "is_negative", + "created_at", + "updated_at", ] - + + def get_survey_template_name(self, obj): + if obj.survey_template: + return obj.survey_template.name + return obj.metadata.get("patient_type", "Event-based Survey") + def validate(self, data): """Validate that exactly one of patient or staff is set""" - patient = data.get('patient') - staff = data.get('staff') - + patient = data.get("patient") + staff = data.get("staff") + # If we're updating, get existing values if self.instance: patient = patient if patient is not None else self.instance.patient staff = staff if staff is not None else self.instance.staff - + if patient and staff: - raise serializers.ValidationError({ - 'patient': 'Cannot specify both patient and staff for a survey', - 'staff': 'Cannot specify both patient and staff for a survey' - }) - + raise serializers.ValidationError( + { + "patient": "Cannot specify both patient and staff for a survey", + "staff": "Cannot specify both patient and staff for a survey", + } + ) + if not patient and not staff: - raise serializers.ValidationError({ - 'patient': 'Must specify either a patient or staff recipient', - 'staff': 'Must specify either a patient or staff recipient' - }) - + raise serializers.ValidationError( + { + "patient": "Must specify either a patient or staff recipient", + "staff": "Must specify either a patient or staff recipient", + } + ) + return data - + def get_survey_url(self, obj): """Get survey URL""" return obj.get_survey_url() @@ -126,185 +202,228 @@ class SurveyInstanceSerializer(serializers.ModelSerializer): class SurveySubmissionSerializer(serializers.Serializer): """ Serializer for submitting survey responses. - + Used by public survey form. """ + responses = serializers.ListField( - child=serializers.DictField(), - help_text="Array of {question_id, numeric_value, text_value, choice_value}" + child=serializers.DictField(), help_text="Array of {question_id, numeric_value, text_value, choice_value}" ) comment = serializers.CharField( - required=False, - allow_blank=True, - allow_null=True, - help_text="Optional patient comment about their experience" + required=False, allow_blank=True, allow_null=True, help_text="Optional patient comment about their experience" ) - + def validate_responses(self, value): """Validate responses""" if not value: raise serializers.ValidationError("At least one response is required") - + for response in value: - if 'question_id' not in response: + if "question_id" not in response: raise serializers.ValidationError("Each response must have question_id") - + return value - + def create(self, validated_data): """ Create survey responses and calculate score. - + This is called when a patient submits the survey. + Uses question_id to reference template questions. """ - survey_instance = self.context['survey_instance'] - responses_data = validated_data['responses'] - + survey_instance = self.context["survey_instance"] + responses_data = validated_data["responses"] + from apps.surveys.models import SurveyResponse, SurveyQuestion from django.utils import timezone - - # Create responses + for response_data in responses_data: - question_id = response_data['question_id'] - choice_value = response_data.get('choice_value', '') - numeric_value = response_data.get('numeric_value') - text_value = response_data.get('text_value', '') - - # For multiple_choice with choice_value but missing numeric/text values, - # look up the choice details from the question - if choice_value and numeric_value is None: + question_id = response_data.get("question_id") + choice_value = response_data.get("choice_value", "") + numeric_value = response_data.get("numeric_value") + text_value = response_data.get("text_value", "") + + if question_id: try: question = SurveyQuestion.objects.get(id=question_id) - if question.question_type == 'multiple_choice': - # Find the selected choice to get its label - selected_choice = None - for choice in question.choices_json: - if str(choice.get('value', '')) == str(choice_value): - selected_choice = choice + if choice_value and numeric_value is None: + for choice in question.choices_json or []: + if str(choice.get("value", "")) == str(choice_value): + if not text_value: + text_value = choice.get("label", choice_value) break - - # Set text_value from choice label if not provided - if not text_value and selected_choice: - text_value = selected_choice.get('label', choice_value) - - # Set numeric_value from choice value if it's numeric - if numeric_value is None: - try: - numeric_value = float(choice_value) - except (ValueError, TypeError): - numeric_value = None + try: + numeric_value = float(choice_value) + except (ValueError, TypeError): + pass + SurveyResponse.objects.create( + survey_instance=survey_instance, + question=question, + numeric_value=numeric_value, + text_value=text_value, + choice_value=choice_value, + response_time_seconds=response_data.get("response_time_seconds"), + ) except SurveyQuestion.DoesNotExist: pass - - SurveyResponse.objects.create( - survey_instance=survey_instance, - question_id=question_id, - numeric_value=numeric_value, - text_value=text_value, - choice_value=choice_value, - response_time_seconds=response_data.get('response_time_seconds') - ) - + # Update survey instance - survey_instance.status = 'completed' + survey_instance.status = "completed" survey_instance.completed_at = timezone.now() survey_instance.save() - + # Calculate score survey_instance.calculate_score() - + # Save optional comment if provided - if 'comment' in validated_data and validated_data['comment']: - survey_instance.comment = validated_data['comment'].strip() - survey_instance.save(update_fields=['comment']) - + if "comment" in validated_data and validated_data["comment"]: + survey_instance.comment = validated_data["comment"].strip() + survey_instance.save(update_fields=["comment"]) + # Queue processing task from apps.surveys.tasks import process_survey_completion + process_survey_completion.delay(str(survey_instance.id)) - + return survey_instance class SurveyTrackingSerializer(serializers.ModelSerializer): """ Survey tracking events serializer. - + Tracks detailed engagement metrics for surveys. """ - survey_template_name = serializers.CharField(source='survey_instance.survey_template.name', read_only=True) - recipient_name = serializers.CharField(source='survey_instance.get_recipient_name', read_only=True) - patient_name = serializers.CharField(source='survey_instance.patient.get_full_name', read_only=True) - staff_name = serializers.CharField(source='survey_instance.staff.get_full_name', read_only=True) - + + survey_template_name = serializers.SerializerMethodField() + recipient_name = serializers.CharField(source="survey_instance.get_recipient_name", read_only=True) + patient_name = serializers.CharField(source="survey_instance.patient.get_full_name", read_only=True) + staff_name = serializers.CharField(source="survey_instance.staff.get_full_name", read_only=True) + class Meta: model = SurveyTracking fields = [ - 'id', 'survey_instance', 'survey_template_name', 'recipient_name', - 'patient_name', 'staff_name', - 'event_type', 'time_on_page', 'total_time_spent', - 'current_question', 'user_agent', 'ip_address', - 'device_type', 'browser', 'country', 'city', 'metadata', - 'created_at' + "id", + "survey_instance", + "survey_template_name", + "recipient_name", + "patient_name", + "staff_name", + "event_type", + "time_on_page", + "total_time_spent", + "current_question", + "user_agent", + "ip_address", + "device_type", + "browser", + "country", + "city", + "metadata", + "created_at", ] - read_only_fields = ['id', 'created_at'] + read_only_fields = ["id", "created_at"] + + def get_survey_template_name(self, obj): + if obj.survey_instance.survey_template: + return obj.survey_instance.survey_template.name + return obj.survey_instance.metadata.get("patient_type", "Event-based Survey") class SurveyInstanceAnalyticsSerializer(serializers.ModelSerializer): """ Enhanced survey instance serializer with tracking analytics. """ - survey_template_name = serializers.CharField(source='survey_template.name', read_only=True) - recipient_name = serializers.CharField(source='get_recipient_name', read_only=True) + + survey_template_name = serializers.SerializerMethodField() + recipient_name = serializers.CharField(source="get_recipient_name", read_only=True) recipient_type = serializers.SerializerMethodField() - patient_name = serializers.CharField(source='patient.get_full_name', read_only=True) - patient_mrn = serializers.CharField(source='patient.mrn', read_only=True) - staff_name = serializers.CharField(source='staff.get_full_name', read_only=True) - staff_email = serializers.EmailField(source='staff.email', read_only=True) + patient_name = serializers.CharField(source="patient.get_full_name", read_only=True) + patient_mrn = serializers.CharField(source="patient.mrn", read_only=True) + staff_name = serializers.CharField(source="staff.get_full_name", read_only=True) + staff_email = serializers.EmailField(source="staff.email", read_only=True) responses = SurveyResponseSerializer(many=True, read_only=True) survey_url = serializers.SerializerMethodField() tracking_events_count = serializers.SerializerMethodField() time_to_complete_minutes = serializers.SerializerMethodField() comment_analysis = serializers.JSONField(read_only=True) - + class Meta: model = SurveyInstance fields = [ - 'id', 'survey_template', 'survey_template_name', - 'recipient_name', 'recipient_type', - 'patient', 'patient_name', 'patient_mrn', - 'staff', 'staff_name', 'staff_email', - 'journey_instance', 'encounter_id', - 'delivery_channel', 'recipient_phone', 'recipient_email', - 'access_token', 'token_expires_at', 'survey_url', - 'status', 'sent_at', 'opened_at', 'completed_at', - 'open_count', 'last_opened_at', 'time_spent_seconds', - 'total_score', 'is_negative', - 'comment', 'comment_analyzed', 'comment_analysis', - 'responses', 'metadata', - 'tracking_events_count', 'time_to_complete_minutes', - 'created_at', 'updated_at' + "id", + "survey_template", + "survey_template_name", + "recipient_name", + "recipient_type", + "patient", + "patient_name", + "patient_mrn", + "staff", + "staff_name", + "staff_email", + "journey_instance", + "encounter_id", + "delivery_channel", + "recipient_phone", + "recipient_email", + "access_token", + "token_expires_at", + "survey_url", + "status", + "sent_at", + "opened_at", + "completed_at", + "open_count", + "last_opened_at", + "time_spent_seconds", + "total_score", + "is_negative", + "comment", + "comment_analyzed", + "comment_analysis", + "responses", + "metadata", + "tracking_events_count", + "time_to_complete_minutes", + "created_at", + "updated_at", ] read_only_fields = [ - 'id', 'access_token', 'token_expires_at', - 'sent_at', 'opened_at', 'completed_at', - 'open_count', 'last_opened_at', 'time_spent_seconds', - 'total_score', 'is_negative', - 'tracking_events_count', 'time_to_complete_minutes', - 'created_at', 'updated_at' + "id", + "access_token", + "token_expires_at", + "sent_at", + "opened_at", + "completed_at", + "open_count", + "last_opened_at", + "time_spent_seconds", + "total_score", + "is_negative", + "tracking_events_count", + "time_to_complete_minutes", + "created_at", + "updated_at", ] - + def get_survey_url(self, obj): """Get survey URL""" return obj.get_survey_url() - + + def get_survey_template_name(self, obj): + if obj.survey_template: + return obj.survey_template.name + return obj.metadata.get("patient_type", "Event-based Survey") + def get_recipient_type(self, obj): """Get recipient type (patient or staff)""" - return 'staff' if obj.staff else 'patient' - + return "staff" if obj.staff else "patient" + def get_tracking_events_count(self, obj): """Get count of tracking events""" return obj.tracking_events.count() - + def get_time_to_complete_minutes(self, obj): """Calculate time to complete in minutes""" if obj.sent_at and obj.completed_at: @@ -316,17 +435,32 @@ class SurveyInstanceAnalyticsSerializer(serializers.ModelSerializer): class PublicSurveySerializer(serializers.ModelSerializer): """ Public survey serializer for patient-facing survey form. - - Excludes sensitive information. + + Excludes sensitive information. Uses template questions. """ - survey_name = serializers.CharField(source='survey_template.name', read_only=True) - survey_name_ar = serializers.CharField(source='survey_template.name_ar', read_only=True) - questions = SurveyQuestionSerializer(source='survey_template.questions', many=True, read_only=True) - + + survey_name = serializers.SerializerMethodField() + survey_name_ar = serializers.SerializerMethodField() + questions = serializers.SerializerMethodField() + class Meta: model = SurveyInstance - fields = [ - 'id', 'survey_name', 'survey_name_ar', - 'questions', 'status', 'completed_at' - ] - read_only_fields = ['id', 'status', 'completed_at'] + fields = ["id", "survey_name", "survey_name_ar", "questions", "status", "completed_at"] + read_only_fields = ["id", "status", "completed_at"] + + def get_survey_name(self, obj): + if obj.survey_template: + return obj.survey_template.name + return "Patient Experience Survey" + + def get_survey_name_ar(self, obj): + if obj.survey_template: + return obj.survey_template.name_ar + return "" + + def get_questions(self, obj): + from django.db.models import Q + + patient_events = set(obj.metadata.get("event_types", [])) + qs = obj.survey_template.questions.filter(Q(is_base=True) | Q(event_type__in=patient_events)).order_by("order") + return SurveyQuestionSerializer(qs, many=True).data diff --git a/apps/surveys/services.py b/apps/surveys/services.py index f7972f5..3594d83 100644 --- a/apps/surveys/services.py +++ b/apps/surveys/services.py @@ -9,6 +9,7 @@ This service handles: Uses NotificationService for all delivery operations. """ + from django.conf import settings from django.utils import timezone import logging @@ -18,113 +19,117 @@ logger = logging.getLogger(__name__) class SurveyDeliveryService: """Service for delivering surveys to patients""" - + @staticmethod def generate_survey_url(survey_instance) -> str: """ Generate secure survey URL with access token. - + Args: survey_instance: SurveyInstance object - + Returns: Full survey URL """ - base_url = getattr(settings, 'SURVEY_BASE_URL', 'http://localhost:8000') + base_url = getattr(settings, "SURVEY_BASE_URL", "http://localhost:8000") survey_path = survey_instance.get_survey_url() full_url = f"{base_url}{survey_path}" logger.info(f"Generated survey URL for {survey_instance.id}: {full_url}") return full_url - + @staticmethod - def generate_sms_message(recipient_name: str, survey_url: str, hospital_name: str = None, is_staff: bool = False) -> str: + def generate_sms_message( + recipient_name: str, survey_url: str, hospital_name: str = None, is_staff: bool = False + ) -> str: """ Generate SMS message with survey link. - + Args: recipient_name: Recipient's first name (patient or staff) survey_url: Survey link hospital_name: Optional hospital name is_staff: Whether recipient is staff member - + Returns: SMS message text """ message = f"Dear {recipient_name},\n\n" - + if hospital_name: message += f"Thank you for being part of {hospital_name}. " - + if is_staff: message += "We value your feedback! Please take a moment to complete our staff experience survey:\n\n" else: message += "We value your feedback! Please take a moment to complete our patient experience survey:\n\n" - + message += f"{survey_url}\n\n" message += "This survey will take approximately 2-3 minutes.\n" message += "Thank you for helping us improve our services!" - + return message - + @staticmethod - def generate_email_message(recipient_name: str, survey_url: str, hospital_name: str = None, is_staff: bool = False) -> str: + def generate_email_message( + recipient_name: str, survey_url: str, hospital_name: str = None, is_staff: bool = False + ) -> str: """ Generate email message with survey link. - + Args: recipient_name: Recipient's first name (patient or staff) survey_url: Survey link hospital_name: Optional hospital name is_staff: Whether recipient is staff member - + Returns: Email message text """ message = f"Dear {recipient_name},\n\n" - + if hospital_name: message += f"Thank you for being part of {hospital_name}.\n\n" else: message += "Thank you for being part of our hospital.\n\n" - + if is_staff: message += "We value your feedback and would appreciate it if you could take a moment to complete our staff experience survey.\n\n" message += "Your feedback helps us improve our services and create a better work environment.\n\n" else: message += "We value your feedback and would appreciate it if you could take a moment to complete our patient experience survey.\n\n" message += "Your feedback helps us improve our services and provide better care for all our patients.\n\n" - + message += f"Survey Link: {survey_url}\n\n" message += "This survey will take approximately 2-3 minutes to complete.\n\n" message += "If you have any questions or concerns, please don't hesitate to contact us.\n\n" message += "Thank you for helping us improve our services!\n\n" - + if is_staff: message += "Best regards,\n" message += "Hospital Administration Team" else: message += "Best regards,\n" message += "Patient Experience Team" - + return message - + @staticmethod def send_survey_sms(survey_instance) -> bool: """ Send survey via SMS using NotificationService API. - + Args: survey_instance: SurveyInstance object - + Returns: True if sent successfully, False otherwise """ logger.info(f"Sending SMS for survey {survey_instance.id}, phone={survey_instance.recipient_phone}") - + if not survey_instance.recipient_phone: logger.warning(f"No phone number for survey {survey_instance.id}") return False - + try: # Generate survey URL and message survey_url = SurveyDeliveryService.generate_survey_url(survey_instance) @@ -132,74 +137,63 @@ class SurveyDeliveryService: recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there" # First name only is_staff = survey_instance.staff is not None hospital_name = survey_instance.hospital.name if survey_instance.hospital else None - message = SurveyDeliveryService.generate_sms_message( - recipient_name, survey_url, hospital_name, is_staff - ) - + message = SurveyDeliveryService.generate_sms_message(recipient_name, survey_url, hospital_name, is_staff) + # Use NotificationService for delivery (supports API backend) from apps.notifications.services import NotificationService - + # Build metadata metadata = { - 'survey_id': str(survey_instance.id), - 'hospital_id': str(survey_instance.hospital.id), - 'is_staff_survey': is_staff + "survey_id": str(survey_instance.id), + "hospital_id": str(survey_instance.hospital.id), + "is_staff_survey": is_staff, } - + if survey_instance.patient: - metadata['patient_id'] = str(survey_instance.patient.id) + metadata["patient_id"] = str(survey_instance.patient.id) if survey_instance.staff: - metadata['staff_id'] = str(survey_instance.staff.id) - - # Try API first, fallback to regular SMS - notification_log = NotificationService.send_sms_via_api( - message=message, + metadata["staff_id"] = str(survey_instance.staff.id) + + # Send SMS via notification service (uses Mshastra if configured) + notification_log = NotificationService.send_sms( phone=survey_instance.recipient_phone, + message=message, related_object=survey_instance, - metadata=metadata + metadata=metadata, ) - - # If API is disabled or failed, fallback to regular send_sms - if notification_log is None: - logger.info("SMS API disabled or returned None, falling back to regular SMS") - notification_log = NotificationService.send_sms( - phone=survey_instance.recipient_phone, - message=message, - related_object=survey_instance, - metadata=metadata - ) - + # Update survey instance based on notification status - if notification_log and (notification_log.status == 'sent' or notification_log.status == 'pending'): + if notification_log and (notification_log.status == "sent" or notification_log.status == "pending"): from apps.surveys.models import SurveyStatus + survey_instance.status = SurveyStatus.SENT survey_instance.sent_at = timezone.now() - survey_instance.save(update_fields=['status', 'sent_at']) + survey_instance.save(update_fields=["status", "sent_at"]) logger.info(f"Survey SMS sent successfully to {survey_instance.recipient_phone}") return True else: logger.warning(f"Survey SMS delivery failed for {survey_instance.id}") return False - + except Exception as e: logger.error(f"Error sending SMS for survey {survey_instance.id}: {str(e)}", exc_info=True) return False - + @staticmethod def send_survey_email(survey_instance) -> bool: """ Send survey via Email using NotificationService API. - + Args: survey_instance: SurveyInstance object - + Returns: True if sent successfully, False otherwise """ if not survey_instance.recipient_email: logger.warning(f"No email address for survey {survey_instance.id}") return False - + try: # Generate survey URL and message survey_url = SurveyDeliveryService.generate_survey_url(survey_instance) @@ -207,31 +201,29 @@ class SurveyDeliveryService: recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there" # First name only is_staff = survey_instance.staff is not None hospital_name = survey_instance.hospital.name if survey_instance.hospital else None - message = SurveyDeliveryService.generate_email_message( - recipient_name, survey_url, hospital_name, is_staff - ) - + message = SurveyDeliveryService.generate_email_message(recipient_name, survey_url, hospital_name, is_staff) + # Use NotificationService for delivery (supports API backend) from apps.notifications.services import NotificationService - + # Build metadata metadata = { - 'survey_id': str(survey_instance.id), - 'hospital_id': str(survey_instance.hospital.id), - 'is_staff_survey': is_staff + "survey_id": str(survey_instance.id), + "hospital_id": str(survey_instance.hospital.id), + "is_staff_survey": is_staff, } - + if survey_instance.patient: - metadata['patient_id'] = str(survey_instance.patient.id) + metadata["patient_id"] = str(survey_instance.patient.id) if survey_instance.staff: - metadata['staff_id'] = str(survey_instance.staff.id) - + metadata["staff_id"] = str(survey_instance.staff.id) + # Set email subject if is_staff: - subject = f'Staff Experience Survey - {survey_instance.hospital.name}' + subject = f"Staff Experience Survey - {survey_instance.hospital.name}" else: - subject = f'Patient Experience Survey - {survey_instance.hospital.name}' - + subject = f"Patient Experience Survey - {survey_instance.hospital.name}" + # Try API first, fallback to regular email notification_log = NotificationService.send_email_via_api( message=message, @@ -239,9 +231,9 @@ class SurveyDeliveryService: subject=subject, html_message=None, # Plain text for now related_object=survey_instance, - metadata=metadata + metadata=metadata, ) - + # If API is disabled or failed, fallback to regular send_email if notification_log is None: logger.info("Email API disabled or returned None, falling back to regular email") @@ -250,63 +242,66 @@ class SurveyDeliveryService: subject=subject, message=message, related_object=survey_instance, - metadata=metadata + metadata=metadata, ) - + # Update survey instance based on notification status - if notification_log and (notification_log.status == 'sent' or notification_log.status == 'pending'): + if notification_log and (notification_log.status == "sent" or notification_log.status == "pending"): from apps.surveys.models import SurveyStatus + survey_instance.status = SurveyStatus.SENT survey_instance.sent_at = timezone.now() - survey_instance.save(update_fields=['status', 'sent_at']) + survey_instance.save(update_fields=["status", "sent_at"]) logger.info(f"Survey email sent successfully to {survey_instance.recipient_email}") return True else: logger.warning(f"Survey email delivery failed for {survey_instance.id}") return False - + except Exception as e: logger.error(f"Error sending email for survey {survey_instance.id}: {str(e)}") return False - + @staticmethod def deliver_survey(survey_instance) -> bool: """ Deliver survey based on configured delivery channel. - + Args: survey_instance: SurveyInstance object - + Returns: True if delivered successfully, False otherwise """ - logger.info(f"Delivering survey {survey_instance.id}, channel={survey_instance.delivery_channel}, phone={survey_instance.recipient_phone}") - + logger.info( + f"Delivering survey {survey_instance.id}, channel={survey_instance.delivery_channel}, phone={survey_instance.recipient_phone}" + ) + # Normalize delivery channel to lowercase for comparison - delivery_channel = (survey_instance.delivery_channel or '').lower() - + delivery_channel = (survey_instance.delivery_channel or "").lower() + # Get recipient (patient or staff) recipient = survey_instance.get_recipient() logger.info(f"Survey {survey_instance.id} recipient: {recipient}") - + # Ensure contact info is set - if delivery_channel == 'sms': + if delivery_channel == "sms": if recipient: survey_instance.recipient_phone = survey_instance.recipient_phone or recipient.phone logger.info(f"Survey {survey_instance.id} SMS - recipient_phone: {survey_instance.recipient_phone}") - elif delivery_channel == 'email': + elif delivery_channel == "email": if recipient: - survey_instance.recipient_email = survey_instance.recipient_email or getattr(recipient, 'email', None) - + survey_instance.recipient_email = survey_instance.recipient_email or getattr(recipient, "email", None) + # Save contact info survey_instance.save() - + # Send based on channel - if delivery_channel == 'sms': + if delivery_channel == "sms": return SurveyDeliveryService.send_survey_sms(survey_instance) - elif delivery_channel == 'email': + elif delivery_channel == "email": return SurveyDeliveryService.send_survey_email(survey_instance) - elif delivery_channel == 'whatsapp': + elif delivery_channel == "whatsapp": # TODO: Implement WhatsApp delivery logger.warning(f"WhatsApp delivery not yet implemented for survey {survey_instance.id}") return False diff --git a/apps/surveys/tasks.py b/apps/surveys/tasks.py index f87e9c5..5052dd0 100644 --- a/apps/surveys/tasks.py +++ b/apps/surveys/tasks.py @@ -342,11 +342,12 @@ def create_action_from_negative_survey(survey_instance_id): # Determine category based on survey template or journey stage category = "service_quality" # Default - if survey.survey_template.survey_type == "post_discharge": - category = "clinical_quality" - elif survey.survey_template.survey_type == "inpatient_satisfaction": - category = "service_quality" - elif survey.journey_instance and survey.journey_instance.stage: + if survey.survey_template: + if survey.survey_template.survey_type == "post_discharge": + category = "clinical_quality" + elif survey.survey_template.survey_type == "inpatient_satisfaction": + category = "service_quality" + if survey.journey_instance and survey.journey_instance.stage: stage = survey.journey_instance.stage.lower() if "admission" in stage or "registration" in stage: category = "process_improvement" @@ -356,9 +357,14 @@ def create_action_from_negative_survey(survey_instance_id): category = "process_improvement" # Build description + survey_label = ( + survey.survey_template.name + if survey.survey_template + else survey.metadata.get("patient_type", "Event-based Survey") + ) description_parts = [ f"Negative survey response with score {score:.1f}/5.0", - f"Survey Template: {survey.survey_template.name}", + f"Survey: {survey_label}", ] if survey.comment: @@ -379,7 +385,7 @@ def create_action_from_negative_survey(survey_instance_id): source_type="survey", content_type=survey_ct, object_id=survey.id, - title=f"Negative Survey: {survey.survey_template.name} (Score: {score:.1f})", + title=f"Negative Survey: {survey_label} (Score: {score:.1f})", description=description, hospital=survey.hospital, department=None, @@ -389,7 +395,7 @@ def create_action_from_negative_survey(survey_instance_id): status="open", metadata={ "source_survey_id": str(survey.id), - "source_survey_template": survey.survey_template.name, + "source_survey_template": survey_label, "survey_score": score, "is_negative": True, "has_comment": bool(survey.comment), diff --git a/apps/surveys/ui_views.py b/apps/surveys/ui_views.py index c921082..7f1614b 100644 --- a/apps/surveys/ui_views.py +++ b/apps/surveys/ui_views.py @@ -7,7 +7,7 @@ from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Q, Prefetch, Avg, Count, F, Case, When, IntegerField from django.db.models.functions import TruncDate -from django.http import FileResponse, HttpResponse +from django.http import FileResponse, HttpResponse, 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 @@ -58,9 +58,9 @@ def survey_instance_list(request): if user.is_px_admin(): pass # See all elif user.is_hospital_admin() and user.hospital: - queryset = queryset.filter(survey_template__hospital=user.hospital) + queryset = queryset.filter(hospital=user.hospital) elif user.hospital: - queryset = queryset.filter(survey_template__hospital=user.hospital) + queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() @@ -79,7 +79,7 @@ def survey_instance_list(request): hospital_filter = request.GET.get("hospital") if hospital_filter: - queryset = queryset.filter(survey_template__hospital_id=hospital_filter) + queryset = queryset.filter(hospital_id=hospital_filter) # Search search_query = request.GET.get("search") @@ -200,6 +200,8 @@ def survey_instance_detail(request, pk): "template_average": round(template_average, 2), "related_surveys": related_surveys, "question_stats": question_stats, + "visit_timeline": survey.metadata.get("visit_timeline", []), + "survey_url": SurveyDeliveryService.generate_survey_url(survey), } return render(request, "surveys/instance_detail.html", context) @@ -211,22 +213,18 @@ def survey_template_list(request): """Survey templates list view""" queryset = SurveyTemplate.objects.select_related("hospital").prefetch_related("questions") - # Apply RBAC filters user = request.user if user.is_px_admin(): - # PX Admins see templates for their selected hospital (from session) tenant_hospital = getattr(request, "tenant_hospital", None) if tenant_hospital: queryset = queryset.filter(hospital=tenant_hospital) else: - # If no hospital selected, show none (user needs to select a hospital) queryset = queryset.none() elif user.hospital: queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() - # Apply filters survey_type = request.GET.get("survey_type") if survey_type: queryset = queryset.filter(survey_type=survey_type) @@ -241,10 +239,8 @@ def survey_template_list(request): elif is_active == "false": queryset = queryset.filter(is_active=False) - # Ordering queryset = queryset.order_by("hospital", "survey_type", "name") - # Pagination page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get("page", 1) @@ -263,7 +259,6 @@ def survey_template_list(request): @login_required def survey_template_create(request): """Create a new survey template with questions""" - # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to create survey templates.") @@ -303,14 +298,12 @@ def survey_template_detail(request, pk): """View survey template details""" template = get_object_or_404(SurveyTemplate.objects.select_related("hospital").prefetch_related("questions"), pk=pk) - # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): if user.hospital and template.hospital != user.hospital: messages.error(request, "You don't have permission to view this template.") return redirect("surveys:template_list") - # Get statistics total_instances = template.instances.count() completed_instances = template.instances.filter(status="completed").count() negative_instances = template.instances.filter(is_negative=True).count() @@ -337,7 +330,6 @@ def survey_template_edit(request, pk): """Edit an existing survey template with questions""" template = get_object_or_404(SurveyTemplate, pk=pk) - # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): if user.hospital and template.hospital != user.hospital: @@ -373,7 +365,6 @@ def survey_template_delete(request, pk): """Delete a survey template""" template = get_object_or_404(SurveyTemplate, pk=pk) - # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): if user.hospital and template.hospital != user.hospital: @@ -393,9 +384,6 @@ def survey_template_delete(request, pk): return render(request, "surveys/template_confirm_delete.html", context) -@block_source_user -@login_required -@require_http_methods(["POST"]) def survey_log_patient_contact(request, pk): """ Log patient contact for negative survey. @@ -1361,7 +1349,6 @@ def survey_analytics_report_view_inline(request, filename): """ import os from django.conf import settings - from django.http import FileResponse, HttpResponse user = request.user diff --git a/apps/surveys/urls.py b/apps/surveys/urls.py index 35b08f8..78c8402 100644 --- a/apps/surveys/urls.py +++ b/apps/surveys/urls.py @@ -11,64 +11,72 @@ from .views import ( from .analytics_views import SurveyAnalyticsViewSet, SurveyTrackingViewSet from . import public_views, ui_views, his_views -app_name = 'surveys' +app_name = "surveys" router = DefaultRouter() -router.register(r'api/templates', SurveyTemplateViewSet, basename='survey-template-api') -router.register(r'api/questions', SurveyQuestionViewSet, basename='survey-question-api') -router.register(r'api/instances', SurveyInstanceViewSet, basename='survey-instance-api') -router.register(r'api/responses', SurveyResponseViewSet, basename='survey-response-api') -router.register(r'api/analytics', SurveyAnalyticsViewSet, basename='survey-analytics-api') -router.register(r'api/tracking', SurveyTrackingViewSet, basename='survey-tracking-api') +router.register(r"api/templates", SurveyTemplateViewSet, basename="survey-template-api") +router.register(r"api/questions", SurveyQuestionViewSet, basename="survey-question-api") +router.register(r"api/instances", SurveyInstanceViewSet, basename="survey-instance-api") +router.register(r"api/responses", SurveyResponseViewSet, basename="survey-response-api") +router.register(r"api/analytics", SurveyAnalyticsViewSet, basename="survey-analytics-api") +router.register(r"api/tracking", SurveyTrackingViewSet, basename="survey-tracking-api") urlpatterns = [ # Public survey pages (no auth required) - path('invalid/', public_views.invalid_token, name='invalid_token'), - + path("invalid/", public_views.invalid_token, name="invalid_token"), # UI Views (authenticated) - specific paths first - path('send/', ui_views.manual_survey_send, name='manual_send'), - path('send/phone/', ui_views.manual_survey_send_phone, name='manual_send_phone'), - path('send/csv/', ui_views.manual_survey_send_csv, name='manual_send_csv'), - + path("send/", ui_views.manual_survey_send, name="manual_send"), + path("send/phone/", ui_views.manual_survey_send_phone, name="manual_send_phone"), + path("send/csv/", ui_views.manual_survey_send_csv, name="manual_send_csv"), # HIS Patient Import - path('his-import/', his_views.his_patient_import, name='his_patient_import'), - path('his-import/review/', his_views.his_patient_review, name='his_patient_review'), - path('his-import/send/', his_views.his_patient_survey_send, name='his_patient_survey_send'), - + path("his-import/", his_views.his_patient_import, name="his_patient_import"), + path("his-import/review/", his_views.his_patient_review, name="his_patient_review"), + path("his-import/send/", his_views.his_patient_survey_send, name="his_patient_survey_send"), # Bulk Survey Jobs - path('bulk-jobs/', his_views.bulk_job_list, name='bulk_job_list'), - path('bulk-jobs//', his_views.bulk_job_status, name='bulk_job_status'), - path('reports/', ui_views.survey_analytics_reports, name='analytics_reports'), - path('reports//view/', ui_views.survey_analytics_report_view_inline, name='analytics_report_view_inline'), - path('reports//download/', ui_views.survey_analytics_report_download, name='analytics_report_download'), - path('reports//delete/', ui_views.survey_analytics_report_delete, name='analytics_report_delete'), - path('reports//', ui_views.survey_analytics_report_view, name='analytics_report_view'), - + path("bulk-jobs/", his_views.bulk_job_list, name="bulk_job_list"), + path("bulk-jobs//", his_views.bulk_job_status, name="bulk_job_status"), + path("reports/", ui_views.survey_analytics_reports, name="analytics_reports"), + path( + "reports//view/", + ui_views.survey_analytics_report_view_inline, + name="analytics_report_view_inline", + ), + path( + "reports//download/", ui_views.survey_analytics_report_download, name="analytics_report_download" + ), + path("reports//delete/", ui_views.survey_analytics_report_delete, name="analytics_report_delete"), + path("reports//", ui_views.survey_analytics_report_view, name="analytics_report_view"), # Enhanced Reports (separate report per survey type) - path('enhanced-reports/', ui_views.enhanced_survey_reports_list, name='enhanced_reports_list'), - path('enhanced-reports/generate/', ui_views.generate_enhanced_report_ui, name='generate_enhanced_report'), - path('enhanced-reports//', ui_views.enhanced_survey_report_view, name='enhanced_report_view'), - path('enhanced-reports//', ui_views.enhanced_survey_report_file, name='enhanced_report_file'), - path('comments/', ui_views.survey_comments_list, name='survey_comments_list'), - path('instances/', ui_views.survey_instance_list, name='instance_list'), - path('instances//', ui_views.survey_instance_detail, name='instance_detail'), - path('instances//log-contact/', ui_views.survey_log_patient_contact, name='log_patient_contact'), - path('instances//send-satisfaction/', ui_views.survey_send_satisfaction_feedback, name='send_satisfaction_feedback'), - path('templates/', ui_views.survey_template_list, name='template_list'), - path('templates/create/', ui_views.survey_template_create, name='template_create'), - path('templates//', ui_views.survey_template_detail, name='template_detail'), - path('templates//edit/', ui_views.survey_template_edit, name='template_edit'), - path('templates//delete/', ui_views.survey_template_delete, name='template_delete'), - + path("enhanced-reports/", ui_views.enhanced_survey_reports_list, name="enhanced_reports_list"), + path("enhanced-reports/generate/", ui_views.generate_enhanced_report_ui, name="generate_enhanced_report"), + path("enhanced-reports//", ui_views.enhanced_survey_report_view, name="enhanced_report_view"), + path( + "enhanced-reports//", + ui_views.enhanced_survey_report_file, + name="enhanced_report_file", + ), + path("comments/", ui_views.survey_comments_list, name="survey_comments_list"), + path("instances/", ui_views.survey_instance_list, name="instance_list"), + path("instances//", ui_views.survey_instance_detail, name="instance_detail"), + path("instances//log-contact/", ui_views.survey_log_patient_contact, name="log_patient_contact"), + path( + "instances//send-satisfaction/", + ui_views.survey_send_satisfaction_feedback, + name="send_satisfaction_feedback", + ), + # Survey Templates (manual/ad-hoc surveys) + path("templates/", ui_views.survey_template_list, name="template_list"), + path("templates/create/", ui_views.survey_template_create, name="template_create"), + path("templates//", ui_views.survey_template_detail, name="template_detail"), + path("templates//edit/", ui_views.survey_template_edit, name="template_edit"), + path("templates//delete/", ui_views.survey_template_delete, name="template_delete"), # Public API endpoints (no auth required) - path('public//', PublicSurveyViewSet.as_view({'get': 'retrieve'}), name='public-survey'), - path('public//submit/', PublicSurveyViewSet.as_view({'post': 'submit'}), name='public-survey-submit'), - + path("public//", PublicSurveyViewSet.as_view({"get": "retrieve"}), name="public-survey"), + path("public//submit/", PublicSurveyViewSet.as_view({"post": "submit"}), name="public-survey-submit"), # Authenticated API endpoints - path('', include(router.urls)), - + path("", include(router.urls)), # Public survey token access (requires /s/ prefix) - path('s//', public_views.survey_form, name='survey_form'), - path('s//thank-you/', public_views.thank_you, name='thank_you'), - path('s//track-start/', public_views.track_survey_start, name='track_survey_start'), + path("s//", public_views.survey_form, name="survey_form"), + path("s//thank-you/", public_views.thank_you, name="thank_you"), + path("s//track-start/", public_views.track_survey_start, name="track_survey_start"), ] diff --git a/apps/surveys/views.py b/apps/surveys/views.py index f2801a9..f9c97f6 100644 --- a/apps/surveys/views.py +++ b/apps/surveys/views.py @@ -1,6 +1,7 @@ """ Surveys views and viewsets """ + from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status, viewsets @@ -11,7 +12,12 @@ from rest_framework.response import Response from apps.accounts.permissions import IsPXAdminOrHospitalAdmin from apps.core.services import AuditService -from .models import SurveyInstance, SurveyQuestion, SurveyResponse, SurveyTemplate +from .models import ( + SurveyInstance, + SurveyQuestion, + SurveyResponse, + SurveyTemplate, +) from .serializers import ( PublicSurveySerializer, SurveyInstanceSerializer, @@ -30,17 +36,18 @@ class SurveyTemplateViewSet(viewsets.ModelViewSet): - PX Admins and Hospital Admins can manage templates - Others can view templates """ + queryset = SurveyTemplate.objects.all() serializer_class = SurveyTemplateSerializer permission_classes = [IsAuthenticated] - filterset_fields = ['survey_type', 'hospital', 'is_active', 'hospital__organization'] - search_fields = ['name', 'name_ar', 'description'] - ordering_fields = ['name', 'created_at'] - ordering = ['hospital', 'name'] + filterset_fields = ["survey_type", "hospital", "is_active", "hospital__organization"] + search_fields = ["name", "name_ar", "description"] + ordering_fields = ["name", "created_at"] + ordering = ["hospital", "name"] def get_queryset(self): """Filter templates based on user role""" - queryset = super().get_queryset().select_related('hospital').prefetch_related('questions') + queryset = super().get_queryset().select_related("hospital").prefetch_related("questions") user = self.request.user # PX Admins see all templates @@ -65,16 +72,17 @@ class SurveyQuestionViewSet(viewsets.ModelViewSet): Permissions: - PX Admins and Hospital Admins can manage questions """ + queryset = SurveyQuestion.objects.all() serializer_class = SurveyQuestionSerializer permission_classes = [IsAuthenticated, IsPXAdminOrHospitalAdmin] - filterset_fields = ['survey_template', 'question_type', 'is_required'] - search_fields = ['text', 'text_ar'] - ordering_fields = ['order', 'created_at'] - ordering = ['survey_template', 'order'] + filterset_fields = ["survey_template", "question_type", "is_required"] + search_fields = ["text", "text_ar"] + ordering_fields = ["order", "created_at"] + ordering = ["survey_template", "order"] def get_queryset(self): - queryset = super().get_queryset().select_related('survey_template') + queryset = super().get_queryset().select_related("survey_template") user = self.request.user # PX Admins see all questions @@ -96,23 +104,31 @@ class SurveyInstanceViewSet(viewsets.ModelViewSet): - All authenticated users can view survey instances - PX Admins and Hospital Admins can create/manage instances """ + queryset = SurveyInstance.objects.all() serializer_class = SurveyInstanceSerializer permission_classes = [IsAuthenticated] filterset_fields = [ - 'survey_template', 'patient', 'status', - 'delivery_channel', 'is_negative', 'journey_instance', - 'survey_template__hospital__organization' + "survey_template", + "patient", + "status", + "delivery_channel", + "is_negative", + "journey_instance", + "survey_template__hospital__organization", ] - search_fields = ['patient__mrn', 'patient__first_name', 'patient__last_name', 'encounter_id'] - ordering_fields = ['sent_at', 'completed_at', 'created_at'] - ordering = ['-created_at'] + search_fields = ["patient__mrn", "patient__first_name", "patient__last_name", "encounter_id"] + ordering_fields = ["sent_at", "completed_at", "created_at"] + ordering = ["-created_at"] def get_queryset(self): """Filter survey instances based on user role""" - queryset = super().get_queryset().select_related( - 'survey_template', 'patient', 'journey_instance' - ).prefetch_related('responses') + queryset = ( + super() + .get_queryset() + .select_related("survey_template", "patient", "journey_instance") + .prefetch_related("responses") + ) user = self.request.user @@ -130,22 +146,20 @@ class SurveyInstanceViewSet(viewsets.ModelViewSet): return queryset.none() - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def resend(self, request, pk=None): """Resend survey invitation""" survey_instance = self.get_object() - if survey_instance.status == 'completed': - return Response( - {'error': 'Cannot resend completed survey'}, - status=status.HTTP_400_BAD_REQUEST - ) + if survey_instance.status == "completed": + return Response({"error": "Cannot resend completed survey"}, status=status.HTTP_400_BAD_REQUEST) # Queue survey send task from apps.surveys.tasks import send_survey_reminder + send_survey_reminder.delay(str(survey_instance.id)) - return Response({'message': 'Survey invitation queued for resend'}) + return Response({"message": "Survey invitation queued for resend"}) class SurveyResponseViewSet(viewsets.ReadOnlyModelViewSet): @@ -154,14 +168,15 @@ class SurveyResponseViewSet(viewsets.ReadOnlyModelViewSet): Responses are created via the public survey submission endpoint. """ + queryset = SurveyResponse.objects.all() serializer_class = SurveyResponseSerializer permission_classes = [IsAuthenticated] - filterset_fields = ['survey_instance', 'question'] - ordering = ['survey_instance', 'question__order'] + filterset_fields = ["survey_instance", "question"] + ordering = ["survey_instance", "question__order"] def get_queryset(self): - queryset = super().get_queryset().select_related('survey_instance', 'question') + queryset = super().get_queryset().select_related("survey_instance", "question") user = self.request.user # PX Admins see all responses @@ -170,11 +185,11 @@ class SurveyResponseViewSet(viewsets.ReadOnlyModelViewSet): # Hospital Admins see responses for their hospital if user.is_hospital_admin() and user.hospital: - return queryset.filter(survey_instance__survey_template__hospital=user.hospital) + return queryset.filter(survey_instance__hospital=user.hospital) # Others see responses for their hospital if user.hospital: - return queryset.filter(survey_instance__survey_template__hospital=user.hospital) + return queryset.filter(survey_instance__hospital=user.hospital) return queryset.none() @@ -185,6 +200,7 @@ class PublicSurveyViewSet(viewsets.GenericViewSet): No authentication required - uses secure token. """ + permission_classes = [AllowAny] def retrieve(self, request, token=None): @@ -194,35 +210,30 @@ class PublicSurveyViewSet(viewsets.GenericViewSet): GET /api/surveys/public/{token}/ """ survey_instance = get_object_or_404( - SurveyInstance.objects.select_related('survey_template').prefetch_related( - 'survey_template__questions' - ), - access_token=token + SurveyInstance.objects.select_related("survey_template").prefetch_related("survey_template__questions"), + access_token=token, ) # Check if token expired if survey_instance.token_expires_at and survey_instance.token_expires_at < timezone.now(): - return Response( - {'error': 'Survey link has expired'}, - status=status.HTTP_410_GONE - ) + return Response({"error": "Survey link has expired"}, status=status.HTTP_410_GONE) # Check if already completed - if survey_instance.status == 'completed': + if survey_instance.status == "completed": return Response( - {'error': 'Survey already completed', 'completed_at': survey_instance.completed_at}, - status=status.HTTP_410_GONE + {"error": "Survey already completed", "completed_at": survey_instance.completed_at}, + status=status.HTTP_410_GONE, ) # Mark as opened if first time if not survey_instance.opened_at: survey_instance.opened_at = timezone.now() - survey_instance.save(update_fields=['opened_at']) + survey_instance.save(update_fields=["opened_at"]) serializer = PublicSurveySerializer(survey_instance) return Response(serializer.data) - @action(detail=False, methods=['post'], url_path='(?P[^/.]+)/submit') + @action(detail=False, methods=["post"], url_path="(?P[^/.]+)/submit") def submit(self, request, token=None): """ Submit survey responses. @@ -237,34 +248,25 @@ class PublicSurveyViewSet(viewsets.GenericViewSet): ] } """ - survey_instance = get_object_or_404( - SurveyInstance, - access_token=token - ) + survey_instance = get_object_or_404(SurveyInstance, access_token=token) # Check if token expired if survey_instance.token_expires_at and survey_instance.token_expires_at < timezone.now(): - return Response( - {'error': 'Survey link has expired'}, - status=status.HTTP_410_GONE - ) + return Response({"error": "Survey link has expired"}, status=status.HTTP_410_GONE) # Check if already completed - if survey_instance.status == 'completed': - return Response( - {'error': 'Survey already completed'}, - status=status.HTTP_400_BAD_REQUEST - ) + if survey_instance.status == "completed": + return Response({"error": "Survey already completed"}, status=status.HTTP_400_BAD_REQUEST) # Validate and create responses - serializer = SurveySubmissionSerializer( - data=request.data, - context={'survey_instance': survey_instance} - ) + serializer = SurveySubmissionSerializer(data=request.data, context={"survey_instance": survey_instance}) serializer.is_valid(raise_exception=True) serializer.save() - return Response({ - 'message': 'Survey submitted successfully', - 'score': float(survey_instance.total_score) if survey_instance.total_score else None - }, status=status.HTTP_201_CREATED) + return Response( + { + "message": "Survey submitted successfully", + "score": float(survey_instance.total_score) if survey_instance.total_score else None, + }, + status=status.HTTP_201_CREATED, + ) diff --git a/config/celery.py b/config/celery.py index dd7b172..4e4ec92 100644 --- a/config/celery.py +++ b/config/celery.py @@ -1,26 +1,28 @@ """ Celery configuration for PX360 project. """ + import os from celery import Celery from celery.schedules import crontab # Set the default Django settings module for the 'celery' program. -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") # Apply zoneinfo compatibility patch for django-celery-beat with Python 3.12+ # This must be done before Celery app is created from config.celery_scheduler import apply_tzcrontab_patch + apply_tzcrontab_patch() -app = Celery('px360') +app = Celery("px360") # Using a string here means the worker doesn't have to serialize # the configuration object to child processes. # - namespace='CELERY' means all celery-related configuration keys # should have a `CELERY_` prefix. -app.config_from_object('django.conf:settings', namespace='CELERY') +app.config_from_object("django.conf:settings", namespace="CELERY") # Load task modules from all registered Django apps. app.autodiscover_tasks() @@ -28,17 +30,17 @@ app.autodiscover_tasks() # Celery Beat schedule for periodic tasks app.conf.beat_schedule = { # Process unprocessed integration events every 1 minute - 'process-integration-events': { - 'task': 'apps.integrations.tasks.process_pending_events', - 'schedule': crontab(minute='*/1'), + "process-integration-events": { + "task": "apps.integrations.tasks.process_pending_events", + "schedule": crontab(minute="*/1"), }, - # Fetch surveys from HIS every 5 minutes - 'fetch-his-surveys': { - 'task': 'apps.integrations.tasks.fetch_his_surveys', - 'schedule': crontab(minute='*/2'), # Every 5 minutes - 'options': { - 'expires': 240, # Task expires after 4 minutes if not picked up - } + # Fetch patient data from HIS every 5 minutes + "fetch-his-surveys": { + "task": "apps.integrations.tasks.fetch_his_surveys", + "schedule": crontab(minute="*/5"), + "options": { + "expires": 300, + }, }, # TEST TASK - Fetch from JSON file (uncomment for testing, remove when done) # 'test-fetch-his-surveys-from-json': { @@ -46,98 +48,115 @@ app.conf.beat_schedule = { # 'schedule': crontab(minute='*/5'), # Every 5 minutes # }, # Send pending scheduled surveys every 10 minutes - 'send-pending-scheduled-surveys': { - 'task': 'apps.surveys.tasks.send_pending_scheduled_surveys', - 'schedule': crontab(minute='*/10'), # Every 10 minutes + "send-pending-scheduled-surveys": { + "task": "apps.surveys.tasks.send_pending_scheduled_surveys", + "schedule": crontab(minute="*/10"), # Every 10 minutes }, # Check for overdue complaints every 15 minutes - 'check-overdue-complaints': { - 'task': 'apps.complaints.tasks.check_overdue_complaints', - 'schedule': crontab(minute='*/15'), + "check-overdue-complaints": { + "task": "apps.complaints.tasks.check_overdue_complaints", + "schedule": crontab(minute="*/15"), }, # Check for overdue actions every 15 minutes - 'check-overdue-actions': { - 'task': 'apps.px_action_center.tasks.check_overdue_actions', - 'schedule': crontab(minute='*/15'), + "check-overdue-actions": { + "task": "apps.px_action_center.tasks.check_overdue_actions", + "schedule": crontab(minute="*/15"), }, # Send SLA reminders every hour - 'send-sla-reminders': { - 'task': 'apps.complaints.tasks.send_sla_reminders', - 'schedule': crontab(minute=0), # Every hour at minute 0 + "send-sla-reminders": { + "task": "apps.complaints.tasks.send_sla_reminders", + "schedule": crontab(minute=0), # Every hour at minute 0 }, # Check for overdue explanation requests every 15 minutes - 'check-overdue-explanation-requests': { - 'task': 'apps.complaints.tasks.check_overdue_explanation_requests', - 'schedule': crontab(minute='*/15'), + "check-overdue-explanation-requests": { + "task": "apps.complaints.tasks.check_overdue_explanation_requests", + "schedule": crontab(minute="*/15"), }, # Send explanation reminders every hour - 'send-explanation-reminders': { - 'task': 'apps.complaints.tasks.send_explanation_reminders', - 'schedule': crontab(minute=0), # Every hour at minute 0 + "send-explanation-reminders": { + "task": "apps.complaints.tasks.send_explanation_reminders", + "schedule": crontab(minute=0), # Every hour at minute 0 + }, + # Check for overdue inquiries every 15 minutes + "check-overdue-inquiries": { + "task": "apps.complaints.tasks.check_overdue_inquiries", + "schedule": crontab(minute="*/15"), + }, + # Send inquiry SLA reminders every hour + "send-inquiry-sla-reminders": { + "task": "apps.complaints.tasks.send_inquiry_sla_reminders", + "schedule": crontab(minute=0), # Every hour at minute 0 + }, + # Check for overdue observations every 15 minutes + "check-overdue-observations": { + "task": "apps.observations.tasks.check_overdue_observations", + "schedule": crontab(minute="*/15"), + }, + # Send observation SLA reminders every hour + "send-observation-sla-reminders": { + "task": "apps.observations.tasks.send_observation_sla_reminders", + "schedule": crontab(minute=0), # Every hour at minute 0 }, # Send onboarding reminders every hour - 'send-onboarding-reminders': { - 'task': 'apps.accounts.tasks.send_onboarding_reminders', - 'schedule': crontab(minute=30), # Every hour at minute 30 + "send-onboarding-reminders": { + "task": "apps.accounts.tasks.send_onboarding_reminders", + "schedule": crontab(minute=30), # Every hour at minute 30 }, # Clean up expired invitations daily at midnight - 'cleanup-expired-invitations': { - 'task': 'apps.accounts.tasks.cleanup_expired_invitations', - 'schedule': crontab(hour=0, minute=0), # Daily at midnight + "cleanup-expired-invitations": { + "task": "apps.accounts.tasks.cleanup_expired_invitations", + "schedule": crontab(hour=0, minute=0), # Daily at midnight }, # Calculate daily KPIs at 1 AM - 'calculate-daily-kpis': { - 'task': 'apps.analytics.tasks.calculate_daily_kpis', - 'schedule': crontab(hour=1, minute=0), + "calculate-daily-kpis": { + "task": "apps.analytics.tasks.calculate_daily_kpis", + "schedule": crontab(hour=1, minute=0), + }, + # Fetch doctor ratings from HIS on the 1st of each month at 1 AM (before aggregation) + "fetch-his-doctor-ratings-monthly": { + "task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly", + "schedule": crontab(hour=1, minute=0, day_of_month=1), }, # Calculate physician monthly ratings on the 1st of each month at 2 AM - 'calculate-physician-ratings': { - 'task': 'apps.physicians.tasks.calculate_monthly_ratings', - 'schedule': crontab(hour=2, minute=0, day_of_month=1), + "calculate-physician-ratings": { + "task": "apps.physicians.tasks.calculate_monthly_ratings", + "schedule": crontab(hour=2, minute=0, day_of_month=1), }, - - - - # Scraping schedules - 'scrape-youtube-hourly': { - 'task': 'social.tasks.scrape_youtube_comments', - 'schedule': 3600.0, # Every hour (in seconds) + "scrape-youtube-hourly": { + "task": "social.tasks.scrape_youtube_comments", + "schedule": 3600.0, # Every hour (in seconds) }, - 'scrape-facebook-every-6-hours': { - 'task': 'social.tasks.scrape_facebook_comments', - 'schedule': 6 * 3600.0, # Every 6 hours + "scrape-facebook-every-6-hours": { + "task": "social.tasks.scrape_facebook_comments", + "schedule": 6 * 3600.0, # Every 6 hours }, - 'scrape-instagram-daily': { - 'task': 'social.tasks.scrape_instagram_comments', - 'schedule': crontab(hour=8, minute=0), # Daily at 8:00 AM + "scrape-instagram-daily": { + "task": "social.tasks.scrape_instagram_comments", + "schedule": crontab(hour=8, minute=0), # Daily at 8:00 AM }, - 'scrape-twitter-every-2-hours': { - 'task': 'social.tasks.scrape_twitter_comments', - 'schedule': 2 * 3600.0, # Every 2 hours + "scrape-twitter-every-2-hours": { + "task": "social.tasks.scrape_twitter_comments", + "schedule": 2 * 3600.0, # Every 2 hours }, - 'scrape-linkedin-daily': { - 'task': 'social.tasks.scrape_linkedin_comments', - 'schedule': crontab(hour=9, minute=0), # Daily at 9:00 AM + "scrape-linkedin-daily": { + "task": "social.tasks.scrape_linkedin_comments", + "schedule": crontab(hour=9, minute=0), # Daily at 9:00 AM }, - 'scrape-google-reviews-daily': { - 'task': 'social.tasks.scrape_google_reviews', - 'schedule': crontab(hour=10, minute=0), # Daily at 10:00 AM + "scrape-google-reviews-daily": { + "task": "social.tasks.scrape_google_reviews", + "schedule": crontab(hour=10, minute=0), # Daily at 10:00 AM }, - - - # Commented out - individual platform tasks provide sufficient coverage # 'scrape-all-platforms-daily': { # 'task': 'social.tasks.scrape_all_platforms', # 'schedule': crontab(hour=2, minute=0), # Daily at 2:00 AM # }, - # Analysis schedules - 'analyze-comments-fallback': { - 'task': 'social.tasks.analyze_pending_comments', - 'schedule': 30 * 60.0, # Every 30 minutes - 'kwargs': {'limit': 100}, + "analyze-comments-fallback": { + "task": "social.tasks.analyze_pending_comments", + "schedule": 30 * 60.0, # Every 30 minutes + "kwargs": {"limit": 100}, }, } @@ -145,4 +164,4 @@ app.conf.beat_schedule = { @app.task(bind=True, ignore_result=True) def debug_task(self): """Debug task to test Celery is working.""" - print(f'Request: {self.request!r}') + print(f"Request: {self.request!r}") diff --git a/config/celery_scheduler.py b/config/celery_scheduler.py index 44c7f34..2c7329b 100644 --- a/config/celery_scheduler.py +++ b/config/celery_scheduler.py @@ -8,6 +8,10 @@ which have a `normalize()` method that zoneinfo.ZoneInfo doesn't have. import functools +# Flag to track if patch has been applied +_patch_applied = False + + def apply_tzcrontab_patch(): """ Apply monkey-patch to django_celery_beat.tzcrontab.TzAwareCrontab @@ -43,4 +47,24 @@ def apply_tzcrontab_patch(): if hasattr(self, '_zoneinfo_nowfun'): self.nowfun = self._zoneinfo_nowfun - tzcrontab.TzAwareCrontab.__init__ = patched_init \ No newline at end of file + tzcrontab.TzAwareCrontab.__init__ = patched_init + + +class PatchedDatabaseScheduler: + """ + Wrapper class that applies the zoneinfo patch before importing the real scheduler. + + This class is referenced in CELERY_BEAT_SCHEDULER setting and lazily imports + the actual DatabaseScheduler after applying the patch. + """ + + def __new__(cls, *args, **kwargs): + """Apply patch and return the actual DatabaseScheduler instance.""" + global _patch_applied + + if not _patch_applied: + apply_tzcrontab_patch() + _patch_applied = True + + from django_celery_beat.schedulers import DatabaseScheduler + return DatabaseScheduler(*args, **kwargs) diff --git a/config/settings/base.py b/config/settings/base.py index a0d333b..ad46a24 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -2,6 +2,7 @@ Base settings for PX360 project. This file contains settings common to all environments. """ + import os from pathlib import Path @@ -17,100 +18,100 @@ env = environ.Env( ) # Read .env file if it exists -environ.Env.read_env(os.path.join(BASE_DIR, '.env')) +environ.Env.read_env(os.path.join(BASE_DIR, ".env")) # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env('SECRET_KEY', default='django-insecure-change-this-in-production') +SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-this-in-production") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env('DEBUG') +DEBUG = env("DEBUG") -ALLOWED_HOSTS = env('ALLOWED_HOSTS') +ALLOWED_HOSTS = env("ALLOWED_HOSTS") # Application definition DJANGO_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", ] THIRD_PARTY_APPS = [ - 'rest_framework', - 'rest_framework_simplejwt', - 'django_filters', - 'drf_spectacular', - 'django_celery_beat', + "rest_framework", + "rest_framework_simplejwt", + "django_filters", + "drf_spectacular", + "django_celery_beat", ] LOCAL_APPS = [ - 'apps.core', - 'apps.accounts', - 'apps.organizations', - 'apps.journeys', - 'apps.surveys', - 'apps.complaints', - 'apps.feedback', - 'apps.callcenter', - 'apps.social', - 'apps.px_action_center', - 'apps.analytics', - 'apps.physicians', - 'apps.projects', - 'apps.integrations', - 'apps.notifications', - 'apps.ai_engine', - 'apps.dashboard', - 'apps.appreciation', - 'apps.observations', - 'apps.px_sources', - 'apps.references', - 'apps.standards', - 'apps.simulator', - 'apps.reports', + "apps.core", + "apps.accounts", + "apps.organizations", + "apps.journeys", + "apps.surveys", + "apps.complaints", + "apps.feedback", + "apps.callcenter", + "apps.social", + "apps.px_action_center", + "apps.analytics", + "apps.physicians", + "apps.projects", + "apps.integrations", + "apps.notifications", + "apps.ai_engine", + "apps.dashboard", + "apps.appreciation", + "apps.observations", + "apps.px_sources", + "apps.references", + "apps.standards", + "apps.simulator", + "apps.reports", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.locale.LocaleMiddleware', # i18n support - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'apps.core.middleware.TenantMiddleware', # Multi-tenancy support - 'apps.px_sources.middleware.SourceUserRestrictionMiddleware', # STRICT source user restrictions - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", # i18n support + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "apps.core.middleware.TenantMiddleware", # Multi-tenancy support + "apps.px_sources.middleware.SourceUserRestrictionMiddleware", # STRICT source user restrictions + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'config.urls' +ROOT_URLCONF = "config.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'django.template.context_processors.i18n', - 'apps.core.context_processors.sidebar_counts', - 'apps.core.context_processors.hospital_context', - 'apps.accounts.context_processors.acknowledgement_counts', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.i18n", + "apps.core.context_processors.sidebar_counts", + "apps.core.context_processors.hospital_context", + "apps.accounts.context_processors.acknowledgement_counts", ], }, }, ] -WSGI_APPLICATION = 'config.wsgi.application' +WSGI_APPLICATION = "config.wsgi.application" # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases @@ -119,9 +120,9 @@ WSGI_APPLICATION = 'config.wsgi.application' # } DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -130,32 +131,32 @@ DATABASES = { # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Custom User Model -AUTH_USER_MODEL = 'accounts.User' +AUTH_USER_MODEL = "accounts.User" # Authentication URLs -LOGIN_URL = '/accounts/login/' -LOGIN_REDIRECT_URL = '/' -LOGOUT_REDIRECT_URL = '/accounts/login/' +LOGIN_URL = "/accounts/login/" +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/accounts/login/" # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'Asia/Riyadh' +TIME_ZONE = "Asia/Riyadh" USE_I18N = True @@ -163,26 +164,26 @@ USE_TZ = True # Languages supported (Arabic and English) LANGUAGES = [ - ('en', 'English'), - ('ar', 'Arabic'), + ("en", "English"), + ("ar", "Arabic"), ] # Locale paths for translation files LOCALE_PATHS = [ - BASE_DIR / 'locale', + BASE_DIR / "locale", ] # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ -STATIC_URL = '/static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [ - BASE_DIR / 'static', + BASE_DIR / "static", ] # Media files -MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" # Data upload settings # Increased limit to support bulk patient imports from HIS @@ -199,125 +200,120 @@ STORAGES = { } - # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Django REST Framework REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication', - 'rest_framework.authentication.SessionAuthentication', + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", ], - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", ], - 'DEFAULT_FILTER_BACKENDS': [ - 'django_filters.rest_framework.DjangoFilterBackend', - 'rest_framework.filters.SearchFilter', - 'rest_framework.filters.OrderingFilter', + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", + "rest_framework.filters.OrderingFilter", ], - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 50, - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 50, + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } # JWT Settings from datetime import timedelta SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), - 'ROTATE_REFRESH_TOKENS': True, - 'BLACKLIST_AFTER_ROTATION': True, - 'UPDATE_LAST_LOGIN': True, + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "UPDATE_LAST_LOGIN": True, } # DRF Spectacular (OpenAPI/Swagger) SPECTACULAR_SETTINGS = { - 'TITLE': 'PX360 API', - 'DESCRIPTION': 'Patient Experience 360 Management System API', - 'VERSION': '1.0.0', - 'SERVE_INCLUDE_SCHEMA': False, + "TITLE": "PX360 API", + "DESCRIPTION": "Patient Experience 360 Management System API", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, } # Celery Configuration -CELERY_BROKER_URL = env('CELERY_BROKER_URL', default='redis://localhost:6379/0') -CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND', default='redis://localhost:6379/0') -CELERY_ACCEPT_CONTENT = ['json'] -CELERY_TASK_SERIALIZER = 'json' -CELERY_RESULT_SERIALIZER = 'json' +CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://localhost:6379/0") +CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="redis://localhost:6379/0") +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" CELERY_TIMEZONE = TIME_ZONE CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes # Celery Beat Schedule -CELERY_BEAT_SCHEDULER = 'config.celery_scheduler:PatchedDatabaseScheduler' +CELERY_BEAT_SCHEDULER = "config.celery_scheduler:PatchedDatabaseScheduler" # Logging Configuration LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', - 'style': '{', + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", }, - 'simple': { - 'format': '{levelname} {message}', - 'style': '{', + "simple": { + "format": "{levelname} {message}", + "style": "{", }, }, - 'filters': { - 'require_debug_true': { - '()': 'django.utils.log.RequireDebugTrue', + "filters": { + "require_debug_true": { + "()": "django.utils.log.RequireDebugTrue", }, }, - 'handlers': { - 'console': { - 'level': 'INFO', - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' + "handlers": { + "console": {"level": "INFO", "class": "logging.StreamHandler", "formatter": "verbose"}, + "file": { + "level": "INFO", + "class": "logging.handlers.RotatingFileHandler", + "filename": BASE_DIR / "logs" / "px360.log", + "maxBytes": 1024 * 1024 * 15, # 15MB + "backupCount": 10, + "formatter": "verbose", }, - 'file': { - 'level': 'INFO', - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': BASE_DIR / 'logs' / 'px360.log', - 'maxBytes': 1024 * 1024 * 15, # 15MB - 'backupCount': 10, - 'formatter': 'verbose', - }, - 'integration_file': { - 'level': 'INFO', - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': BASE_DIR / 'logs' / 'integrations.log', - 'maxBytes': 1024 * 1024 * 15, # 15MB - 'backupCount': 10, - 'formatter': 'verbose', + "integration_file": { + "level": "INFO", + "class": "logging.handlers.RotatingFileHandler", + "filename": BASE_DIR / "logs" / "integrations.log", + "maxBytes": 1024 * 1024 * 15, # 15MB + "backupCount": 10, + "formatter": "verbose", }, }, - 'loggers': { - 'django': { - 'handlers': ['console', 'file'], - 'level': 'INFO', - 'propagate': False, + "loggers": { + "django": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, }, - 'apps': { - 'handlers': ['console', 'file'], - 'level': 'INFO', - 'propagate': False, + "apps": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, }, - 'apps.integrations': { - 'handlers': ['console', 'integration_file'], - 'level': 'INFO', - 'propagate': False, + "apps.integrations": { + "handlers": ["console", "integration_file"], + "level": "INFO", + "propagate": False, }, }, } # Create logs directory if it doesn't exist -LOGS_DIR = BASE_DIR / 'logs' +LOGS_DIR = BASE_DIR / "logs" LOGS_DIR.mkdir(exist_ok=True) # PX360 Business Configuration @@ -329,20 +325,22 @@ SURVEY_TOKEN_EXPIRY_DAYS = 30 # SLA Configuration (in hours) SLA_DEFAULTS = { - 'complaint': { - 'low': 72, - 'medium': 48, - 'high': 24, - 'critical': 12, + "complaint": { + "low": 72, + "medium": 48, + "high": 24, + "critical": 12, }, - 'action': { - 'low': 120, - 'medium': 72, - 'high': 48, - 'critical': 24, + "action": { + "low": 120, + "medium": 72, + "high": 48, + "critical": 24, }, } +COMPLAINT_LINK_EXPIRY_DAYS = env.int("COMPLAINT_LINK_EXPIRY_DAYS", default=7) + # AI Configuration (LiteLLM with OpenRouter) OPENROUTER_API_KEY = env('OPENROUTER_API_KEY', default='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') AI_MODEL = env('AI_MODEL', default='z-ai/glm-4.5-air:free') @@ -351,64 +349,75 @@ AI_MAX_TOKENS = env.int('AI_MAX_TOKENS', default=500) # Notification Configuration NOTIFICATION_CHANNELS = { - 'sms': { - 'enabled': env.bool('SMS_ENABLED', default=False), - 'provider': env('SMS_PROVIDER', default='console'), + "sms": { + "enabled": env.bool("SMS_ENABLED", default=False), + "provider": env("SMS_PROVIDER", default="console"), }, - 'whatsapp': { - 'enabled': env.bool('WHATSAPP_ENABLED', default=False), - 'provider': env('WHATSAPP_PROVIDER', default='console'), + "whatsapp": { + "enabled": env.bool("WHATSAPP_ENABLED", default=False), + "provider": env("WHATSAPP_PROVIDER", default="console"), }, - 'email': { - 'enabled': env.bool('EMAIL_ENABLED', default=True), - 'provider': env('EMAIL_PROVIDER', default='console'), + "email": { + "enabled": env.bool("EMAIL_ENABLED", default=True), + "provider": env("EMAIL_PROVIDER", default="console"), }, } +# Twilio Configuration +TWILIO_ACCOUNT_SID = env("TWILIO_ACCOUNT_SID", default="") +TWILIO_AUTH_TOKEN = env("TWILIO_AUTH_TOKEN", default="") +TWILIO_PHONE_NUMBER = env("TWILIO_PHONE_NUMBER", default="") +TWILIO_MESSAGING_SERVICE_SID = env("TWILIO_MESSAGING_SERVICE_SID", default="") + +# Mshastra SMS Configuration +MSHASTRA_USERNAME = env("MSHASTRA_USERNAME", default="") +MSHASTRA_PASSWORD = env("MSHASTRA_PASSWORD", default="") +MSHASTRA_SENDER_ID = env("MSHASTRA_SENDER_ID", default="") + # External API Notification Configuration EXTERNAL_NOTIFICATION_API = { - 'email': { - 'enabled': env.bool('EMAIL_API_ENABLED', default=False), - 'url': env('EMAIL_API_URL', default=''), - 'api_key': env('EMAIL_API_KEY', default=''), - 'auth_method': env('EMAIL_API_AUTH_METHOD', default='bearer'), - 'method': env('EMAIL_API_METHOD', default='POST'), - 'timeout': env.int('EMAIL_API_TIMEOUT', default=10), - 'max_retries': env.int('EMAIL_API_MAX_RETRIES', default=3), - 'retry_delay': env.int('EMAIL_API_RETRY_DELAY', default=2), + "email": { + "enabled": env.bool("EMAIL_API_ENABLED", default=False), + "url": env("EMAIL_API_URL", default=""), + "api_key": env("EMAIL_API_KEY", default=""), + "auth_method": env("EMAIL_API_AUTH_METHOD", default="bearer"), + "method": env("EMAIL_API_METHOD", default="POST"), + "timeout": env.int("EMAIL_API_TIMEOUT", default=10), + "max_retries": env.int("EMAIL_API_MAX_RETRIES", default=3), + "retry_delay": env.int("EMAIL_API_RETRY_DELAY", default=2), }, - 'sms': { - 'enabled': env.bool('SMS_API_ENABLED', default=False), - 'url': env('SMS_API_URL', default=''), - 'api_key': env('SMS_API_KEY', default=''), - 'auth_method': env('SMS_API_AUTH_METHOD', default='bearer'), - 'method': env('SMS_API_METHOD', default='POST'), - 'timeout': env.int('SMS_API_TIMEOUT', default=10), - 'max_retries': env.int('SMS_API_MAX_RETRIES', default=3), - 'retry_delay': env.int('SMS_API_RETRY_DELAY', default=2), + "sms": { + "enabled": env.bool("SMS_API_ENABLED", default=False), + "url": env("SMS_API_URL", default=""), + "api_key": env("SMS_API_KEY", default=""), + "auth_method": env("SMS_API_AUTH_METHOD", default="bearer"), + "method": env("SMS_API_METHOD", default="POST"), + "timeout": env.int("SMS_API_TIMEOUT", default=10), + "max_retries": env.int("SMS_API_MAX_RETRIES", default=3), + "retry_delay": env.int("SMS_API_RETRY_DELAY", default=2), }, } # Email Configuration -EMAIL_BACKEND = env('EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend') -EMAIL_HOST = env('EMAIL_HOST', default='localhost') -EMAIL_PORT = env.int('EMAIL_PORT', default=2525) -EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=False) -EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='') -EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='') -DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@px360.sa') +EMAIL_BACKEND = env("EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend") +EMAIL_HOST = env("EMAIL_HOST", default="localhost") +EMAIL_PORT = env.int("EMAIL_PORT", default=2525) +EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=False) +EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="") +EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="") +DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="noreply@px360.sa") # Security Settings SECURE_BROWSER_XSS_FILTER = True SECURE_CONTENT_TYPE_NOSNIFF = True -X_FRAME_OPTIONS = 'DENY' -SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=False) -SESSION_COOKIE_SECURE = env.bool('SESSION_COOKIE_SECURE', default=False) -CSRF_COOKIE_SECURE = env.bool('CSRF_COOKIE_SECURE', default=False) +X_FRAME_OPTIONS = "DENY" +SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=False) +SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=False) +CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=False) SESSION_COOKIE_HTTPONLY = True CSRF_COOKIE_HTTPONLY = True -SESSION_COOKIE_SAMESITE = 'Lax' -CSRF_COOKIE_SAMESITE = 'Lax' +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SAMESITE = "Lax" # Password Policy Settings PASSWORD_MIN_LENGTH = 8 @@ -421,70 +430,45 @@ LOGIN_ATTEMPT_TIMEOUT_MINUTES = 30 # Session Security SESSION_COOKIE_AGE = 120 * 60 # 2 hours -SESSION_EXPIRE_AT_BROWSER_CLOSE = env.bool('SESSION_EXPIRE_AT_BROWSER_CLOSE', default=True) +SESSION_EXPIRE_AT_BROWSER_CLOSE = env.bool("SESSION_EXPIRE_AT_BROWSER_CLOSE", default=True) SESSION_SAVE_EVERY_REQUEST = True # Multi-Tenancy Settings TENANCY_ENABLED = True -TENANT_MODEL = 'organizations.Hospital' -TENANT_FIELD = 'hospital' +TENANT_MODEL = "organizations.Hospital" +TENANT_FIELD = "hospital" # Tenant isolation level # 'strict' - Complete isolation (users only see their hospital) # 'relaxed' - PX admins can see all hospitals -TENANT_ISOLATION_LEVEL = 'strict' +TENANT_ISOLATION_LEVEL = "strict" +# Social Media API Configuration +YOUTUBE_API_KEY = env('YOUTUBE_API_KEY', default='AIzaSyAem20etP6GkRNMmCyI1pRJF7v8U_xDyMM') +YOUTUBE_CHANNEL_ID = env('YOUTUBE_CHANNEL_ID', default='UCKoEfCXsm4_cQMtqJTvZUVQ') +FACEBOOK_PAGE_ID = env('FACEBOOK_PAGE_ID', default='938104059393026') +FACEBOOK_ACCESS_TOKEN = env('FACEBOOK_ACCESS_TOKEN', default='EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc') +INSTAGRAM_ACCOUNT_ID = env('INSTAGRAM_ACCOUNT_ID', default='17841431861985364') +INSTAGRAM_ACCESS_TOKEN = env('INSTAGRAM_ACCESS_TOKEN', default='EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc') +# Twitter/X Configuration +TWITTER_BEARER_TOKEN = env('TWITTER_BEARER_TOKEN', default=None) +TWITTER_USERNAME = env('TWITTER_USERNAME', default=None) +# LinkedIn Configuration +LINKEDIN_ACCESS_TOKEN = env('LINKEDIN_ACCESS_TOKEN', default=None) +LINKEDIN_ORGANIZATION_ID = env('LINKEDIN_ORGANIZATION_ID', default=None) -#social media settings - -# LINKEDIN API CREDENTIALS -LINKEDIN_CLIENT_ID = '78eu5csx68y5bn' -LINKEDIN_CLIENT_SECRET ='WPL_AP1.Ek4DeQDXuv4INg1K.mGo4CQ==' -LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/LI/' -LINKEDIN_WEBHOOK_VERIFY_TOKEN = "your_random_secret_string_123" - - -# YOUTUBE API CREDENTIALS -# Ensure this matches your Google Cloud Console settings -YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'yt_client_secrets.json' -YOUTUBE_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/YT/' - - - -# Google REVIEWS Configuration -# Ensure you have your client_secrets.json file at this location -GMB_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'gmb_client_secrets.json' -GMB_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/GO/' - - - - - -# X API Configuration -X_CLIENT_ID = 'your_client_id' -X_CLIENT_SECRET = 'your_client_secret' -X_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/X/' -# TIER CONFIGURATION -# Set to True if you have Enterprise Access -# Set to False for Free/Basic/Pro -X_USE_ENTERPRISE = False - - -# --- TIKTOK CONFIG --- -TIKTOK_CLIENT_KEY = 'your_client_key' -TIKTOK_CLIENT_SECRET = 'your_client_secret' -TIKTOK_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/TT/' - - - -# --- META API CONFIG --- -META_APP_ID = '1229882089053768' -META_APP_SECRET = 'b80750bd12ab7f1c21d7d0ca891ba5ab' -META_REDIRECT_URI = 'https://micha-nonparabolic-lovie.ngrok-free.dev/social/callback/META/' -META_WEBHOOK_VERIFY_TOKEN = 'random_secret_string_khanfaheed123456' +# Google Reviews Configuration +GOOGLE_CREDENTIALS_FILE = env('GOOGLE_CREDENTIALS_FILE', default='client_secret.json') +GOOGLE_TOKEN_FILE = env('GOOGLE_TOKEN_FILE', default='token.json') +GOOGLE_LOCATIONS = env.list('GOOGLE_LOCATIONS', default=[]) +# OpenRouter Configuration for AI Comment Analysis +OPENROUTER_API_KEY = env('OPENROUTER_API_KEY', default='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') +OPENROUTER_MODEL = env('OPENROUTER_MODEL', default='google/gemma-3-27b-it:free') +ANALYSIS_BATCH_SIZE = env.int('ANALYSIS_BATCH_SIZE', default=2) +ANALYSIS_ENABLED = env.bool('ANALYSIS_ENABLED', default=True) diff --git a/employee_evaluation.md b/employee_evaluation.md new file mode 100644 index 0000000..deee724 --- /dev/null +++ b/employee_evaluation.md @@ -0,0 +1,385 @@ +🧩 Overall Layout +Header: Display the title "PAD Department – Patients Relations Weekly Dashboard" and the date range "From: 00 JAN 2025 – To: 00 JAN 2025". + +Three columns side by side (on desktop) for Amaal, Abrar, and Rahaf. On mobile, stack vertically. + +Each column contains identical sections (described below) populated with the respective employee’s data. + +Use cards to group related metrics. + +Include ApexCharts for visual representations where indicated (e.g., pie/bar charts for complaint sources, response time distributions, etc.). + +📊 Data Sections per Employee +1. Complaints by Response Time +Table with columns: 24 Hours, 48 Hours, 72 Hours, More than 72 hours, Total. +Also show percentages for each time bucket (relative to employee’s total complaints). + +Employee 24h 48h 72h >72h Total +Amaal 7 2 11 19 39 +Abrar 5 13 9 4 31 +Rahaf 3 11 21 10 45 +Chart suggestion: Stacked bar chart showing distribution per employee, or a pie chart for each employee’s time breakdown. + +2. Complaint Source Breakdown +Counts and percentages for sources: MOH, CCHI, Patients, Patient’s relatives, Insurance company. +Percentages are calculated against the total of these five sources (row "Total" in this group). + +Amaal +Source Count % of Source Total +MOH 4 13.3% +CCHI 5 16.7% +Patients 6 20.0% +Patient’s relatives 7 23.3% +Insurance company 8 26.7% +Total 30 100% +Abrar +Source Count % +MOH 1 10% +CCHI 3 30% +Patients 1 10% +Patient’s relatives 2 20% +Insurance company 3 30% +Total 10 100% +Rahaf +Source Count % +MOH 22 23.4% +CCHI 15 16.0% +Patients 26 27.7% +Patient’s relatives 22 23.4% +Insurance company 9 9.6% +Total 94 100% +Chart suggestion: Horizontal bar chart or donut chart for each employee showing source contribution. + +3. Response Time by Source (CHI vs MOH) +A matrix showing for each time category the number of complaints attributed to CHI and MOH. + +Amaal +Time CHI MOH +24 Hours 1 2 +48 Hours 2 2 +72 Hours 3 6 +>72 Hours 4 0 +Total 10 10 +Abrar +Time CHI MOH +24 Hours 1 2 +48 Hours 2 2 +72 Hours 3 6 +>72 Hours 4 0 +Total 10 10 +*(Note: Abrar’s numbers are identical to Amaal’s in the sample, but verify from the file: Actually from the ASCII, Abrar's matrix: 24h: CHI=1, MOH=2; 48h: CHI=2, MOH=2; 72h: CHI=3, MOH=6; >72h: CHI=4, MOH=0. So same as Amaal.)* + +Rahaf +Time CHI MOH +24 Hours 1 2 +48 Hours 2 2 +72 Hours 3 6 +>72 Hours 4 0 +Total 10 10 +(Rahaf also identical in the sample. These numbers may be placeholders; keep as given.) + +Chart suggestion: Grouped bar chart per time category comparing CHI vs MOH. + +4. Patient Type Breakdown +Counts and percentages for In-Patient, Out-Patient, ER. Percentages are against the total of these three. + +Amaal +Type Count % +In-Patient 12 35.3% +Out-Patient 15 44.1% +ER 7 20.6% +Total 34 100% +Abrar +Type Count % +In-Patient 1 12.5% +Out-Patient 3 37.5% +ER 4 50.0% +Total 8 100% +Rahaf +Type Count % +In-Patient 14 53.8% +Out-Patient 9 34.6% +ER 3 11.5% +Total 26 100% +Chart suggestion: Pie chart for each employee. + +5. Department Type Breakdown +Counts and percentages for Medical, Admin, Nursing, Support Services. Percentages against total of these four. + +Amaal +Department Count % +Medical 3 7.9% +Admin 9 23.7% +Nursing 15 39.5% +Support Services 11 28.9% +Total 38 100% +Abrar +Department Count % +Medical 1 2.1% +Admin 1 2.1% +Nursing 5 10.4% +Support Services 41 85.4% +Total 48 100% +Rahaf +Department Count % +Medical 12 34.3% +Admin 10 28.6% +Nursing 9 25.7% +Support Services 4 11.4% +Total 35 100% +Chart suggestion: Horizontal bar chart. + +6. Delays and Activation +Number of delays in sending the complaint (delay in activation) + +Activated within two hours count + +Percentages are calculated against the employee’s total complaints (from section 1). + +Employee Delays Activated within 2h +Amaal 12 9 +Abrar 6 10 +Rahaf 11 18 +Percentage for delays = delays / total_complaints (Amaal: 12/39≈30.8%, Abrar: 6/31≈19.4%, Rahaf: 11/45≈24.4%) + +Percentage for activated = activated / total_complaints (Amaal: 9/39≈23.1%, Abrar: 10/31≈32.3%, Rahaf: 18/45=40%) + +Display as two metrics per employee with percentages. + +7. Escalated Complaints +Total Escalated Complaints (sum of before 72h, exactly 72h, after 72h) + +Breakdown: + +Before 72 Hours + +72 Hours Exactly + +After 72 Hours + +Resolved Escalated complaint + +Employee Before 72h Exactly 72h After 72h Resolved Total Escalated +Amaal 15 11 3 12 29 +Abrar 12 20 14 9 46 +Rahaf 12 18 3 10 33 +Chart suggestion: Stacked bar showing composition of escalated complaints. + +8. Inquiries +Total Inquiries = Incoming + Outgoing +Employee Incoming Outgoing Total Inquiries +Amaal 15 15 30 +Abrar 10 5 15 +Rahaf 3 11 14 +Incoming Inquiries – by Response Time & Status +Table for Incoming (counts): + +Employee 24h 48h 72h >72h تحت الإجراء (In Progress) تم التواصل (Contacted) تم التواصل ولم يتم الرد (Contacted No Response) +Amaal 1 2 5 7 3 10 2 +Abrar 1 2 0 7 3 5 2 +Rahaf 1 2 0 0 3 5 2 +*Note: The status counts are from the "Inquiries Status" section (rows 86-88). Verify that the sum of statuses equals total incoming? For Amaal: 3+10+2=15 (matches incoming). Good.* + +Outgoing Inquiries – by Response Time & Status +Employee 24h 48h 72h >72h تحت الإجراء تم التواصل تم التواصل ولم يتم الرد +Amaal 1 2 5 7 2 10 3 +Abrar 1 1 3 0 2 1 2 +Rahaf 1 4 3 3 2 6 3 +Percentages for each time/status cell can be calculated against total incoming/outgoing respectively. + +Chart suggestion: Grouped bars for incoming vs outgoing per employee, or stacked bars for status distribution. + +Inquiry Type Details (Incoming & Outgoing) +List of inquiry types with counts: + +Incoming Types (appear under "incoming inquiries" table): + +Type Amaal Abrar Rahaf +Contact the doctor 3 3 3 +Sick-Leave - Medical Reports 8 4 4 +Blood test result 4 3 3 +Raise a Complaint 4 4 4 +problem with the app 3 3 3 +Ask about medication 3 3 3 +Insurance request status 3 3 3 +general question 3 3 3 +Total 31 26 26 +Note: Totals may not match incoming totals because these are separate tallies; keep as given. + +Outgoing Types: + +Type Amaal Abrar Rahaf +Contact the doctor 3 0 3 +Sick-Leave - Medical Reports 4 0 1 +Blood test result 3 0 2 +Raise a Complaint 1 1 1 +problem with the app 1 1 1 +Insurance request status 1 1 1 +general question 1 1 1 +Ask about medication 1 1 1 +Total 15 5 11 +Display as two small tables per employee (incoming types, outgoing types) or combine. + +9. Notes +Total Notes per employee: Amaal=22, Abrar=19, Rahaf=15. + +Breakdown by Category and Sub-category (list from rows 97-103): + +Category Sub-category Amaal Abrar Rahaf +Non-Medical IT - App 3 3 3 +Medical LAB 2 2 2 +ER Doctors/Managers/Reception 1 1 1 +Hospital Hospital 1 1 1 +Non-Medical Medical Reports 1 1 1 +Medical Doctors 1 1 1 +*Note: There are only these six rows in the sample; totals add up: Amaal 3+2+1+1+1+1=9, but total notes is 22, so there are missing entries. Keep as given; the AI can display only these.* + +Chart suggestion: Treemap or bar chart for note categories. + +10. Complaint Request & Filling Details +Total sent complaints request = Filled + Not Filled +Employee Filled Not Filled Total Requests +Amaal 7 10 17 +Abrar 5 14 19 +Rahaf 3 3 6 +Complaint Status Breakdown +On hold, Not filled, Filled, From Bar code counts and percentages. + +Employee On hold Not filled Filled From Bar code Total (sum) +Amaal 2 8 3 4 17 +Abrar 2 5 6 6 19 +Rahaf 1 2 2 1 6 +*Percentages are against the total (e.g., Amaal on hold % = 2/17 ≈ 11.8%).* + +Filling Time Breakdown +Same time, within 6 hours, 6 to 24 hours, after 1 day, time not mentioned counts and percentages. + +Employee Same time ≤6h 6-24h >1 day Not mentioned Total +Amaal 6 2 2 5 2 17 +Abrar 6 1 5 5 2 19 +Rahaf 1 2 1 2 6 12 +*Note: Rahaf’s total here is 12, but total requests above is 6 – inconsistency in sample. Use the numbers as given, but note the discrepancy. Possibly the "Total" row in that section sums to something else. We'll keep the provided counts: for Rahaf: same time=1, ≤6h=2, 6-24h=1, >1 day=2, not mentioned=6 → sum=12. We'll display them as is.* + +Chart suggestion: Pie chart for filling time distribution. + +11. Report Completion Tracker +For each employee, a list of reports with True/False status, and overall completion percentage. + +Reports: + +Complaint Report + +Complaint Request Report + +Observation Report + +Incoming Inquiries Report + +Outgoing Inquiries Report + +Extension Report + +Escalated Complaints Report + +Status per employee: + +Report Amaal Abrar Rahaf +Complaint Report True True True +Complaint Request Report False True False +Observation Report True True True +Incoming Inquiries Report True True False +Outgoing Inquiries Report True True False +Extension Report True True False +Escalated Complaints Report False False True +Completion % = (number of True) / 7 + +Employee Completion % +Amaal 5/7 ≈ 71.4% +Abrar 6/7 ≈ 85.7% +Rahaf 3/7 ≈ 42.9% +Display as checklist with checkmarks/crosses and a progress bar. + +📈 Summary / Cross‑Employee Views (from other sheets) +The sheets Complaints Calculations, Inquiries Calculations, and Notes Calculations provide totals across employees. Use these to create summary cards or charts at the top of the dashboard. + +Complaints Calculations (excerpts) +Total Complaints per employee: Amaal=39, Abrar=31, Rahaf=45 → Overall = 115. + +24h/48h/72h/>72h totals across employees: + +24h: 7+5+3 = 15 + +48h: 2+13+11 = 26 + +72h: 11+9+21 = 41 + +72h: 19+4+10 = 33 + +Complaints by Department (sum of all employees): + +Support Services: (Amaal11 + Abrar41 + Rahaf4) = 56 + +Nursing: (15+5+9) = 29 + +Admin: (9+1+10) = 20 + +Medical: (3+1+12) = 16 + +ER: (7+4+3) = 14 + +Out-Patient: (15+3+9) = 27 + +In-Patient: (12+1+14) = 27 + +Insurance company: (8+3+9) = 20 + +Patient’s relatives: (7+2+22) = 31 + +Patients: (6+1+26) = 33 + +CCHI: (5+3+15) = 23 + +MOH: (4+1+22) = 27 + +Inquiries Calculations +Total Incoming Inquiries: Amaal15 + Abrar10 + Rahaf3 = 28 + +Total Outgoing Inquiries: Amaal15 + Abrar5 + Rahaf11 = 31 + +Incoming status and time totals can be summed similarly. + +Notes Calculations +Total Notes: Amaal22 + Abrar19 + Rahaf15 = 56 + +Percentages of total: Amaal 39.3%, Abrar 33.9%, Rahaf 26.8%. + +🎨 Visual Design Guidelines +Use Tailwind CSS for layout and styling (cards, grid, typography, colors). + +Make the dashboard responsive: three columns on large screens, stacked on small. + +Use subtle background colors to separate employee sections. + +All tables should be readable with small font sizes; use text-sm and padding. + +For charts, use ApexCharts. Suggested chart types: + +Complaint response time: Grouped bar (employees on x-axis, time buckets as groups) or stacked bar. + +Complaint source: Pie/donut per employee. + +Inquiry status: Stacked bar per employee. + +Completion tracker: Progress bar per employee. + +Department totals: Horizontal bar chart comparing employees. + +Place summary KPIs (total complaints, total inquiries, total notes) at the top. + +📝 Implementation Notes +The data provided is static; hardcode the numbers as shown. + +Use Arabic text where present (e.g., تحت الإجراء, تم التواصل) – ensure proper RTL support if needed, but the layout is primarily English. + + +Organize sections into collapsible cards if too long, but aim to show all data clearly. \ No newline at end of file diff --git a/rating_data_sample.json b/rating_data_sample.json new file mode 100644 index 0000000..2a5d20a --- /dev/null +++ b/rating_data_sample.json @@ -0,0 +1,39 @@ +{ + "FetchDoctorRatingMAPI1List": [ + { + "DoctorID": "11510", + "EmpNo": "17046", + "DoctorName": "AAMIR USMAN BAIG ", + "DoctorDepartment": "ORTHOPAEDIC", + "HospitalName": "SUWAIDI", + "HospitalID": "2", + "Rating": "5.00", + "RatingDate": "30-Dec-2025 14:06" + }, + { + "DoctorID": "11510", + "EmpNo": "17046", + "DoctorName": "AAMIR USMAN BAIG ", + "DoctorDepartment": "ORTHOPAEDIC", + "HospitalName": "NUZHA", + "HospitalID": "3", + "Rating": "5.00", + "RatingDate": "30-Dec-2025 14:06" + }, + { + "DoctorID": "12975", + "EmpNo": "18753", + "DoctorName": "ABDALLA BASBAR DAFA ALLA BASBAR ", + "DoctorDepartment": "ENDOSCOPY", + "HospitalName": "NUZHA", + "HospitalID": "3", + "Rating": "3.00", + "RatingDate": "21-Dec-2025 14:33" + } + ], + "Code": 200, + "Status": "Success", + "MobileNo": "", + "Message": "", + "Message2L": "" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b5cd1fd..be530ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,6 +40,14 @@ requests-oauthlib==2.0.0 rsa==4.9.1 six==1.17.0 sqlparse==0.5.5 +stack-data==0.6.3 +traitlets==5.14.3 +trio==0.32.0 +trio-websocket==0.12.2 +tweepy==4.16.0 +twilio==9.10.3 +types-PyYAML==6.0.12.20250915 +types-requests==2.32.4.20250913 typing_extensions==4.15.0 tzdata==2025.3 tzlocal==5.3.1 diff --git a/sample_data.json b/sample_data.json new file mode 100644 index 0000000..5804b0c --- /dev/null +++ b/sample_data.json @@ -0,0 +1,267 @@ +{ + "FetchPatientDataTimeStampList": [ + { + "Type": "Patient Demographic details", + "PatientID": "2371172", + "AdmissionID": "811917", + "HospitalID": "3", + "HospitalName": "NUZHA", + "PatientTypeID": "3", + "PatientType": "ED", + "AdmitDate": "01-Jan-2026 00:07", + "DischargeDate": "01-Jan-2026 00:39", + "RegCode": "ALHH.0040068420", + "SSN": "1220796831", + "PatientName": "Aljuri Ibrahim Alhumaid", + "GenderID": "2", + "Gender": "Female", + "FullAge": "3 Month(s)", + "PatientNationality": "SAUDI", + "MobileNo": "0530112229", + "DOB": "17-Nov-2025 00:00", + "ConsultantID": "7797", + "PrimaryDoctor": "12524-MAI ABOELNASR ELBORI ", + "CompanyID": "", + "GradeID": "0", + "CompanyName": "", + "GradeName": "", + "InsuranceCompanyName": "", + "BillType": "CS", + "IsVIP": "0" + }, + { + "Type": "Patient Demographic details", + "PatientID": "1690910", + "AdmissionID": "828647", + "HospitalID": "2", + "HospitalName": "SUWAIDI", + "PatientTypeID": "1", + "PatientType": "OP", + "AdmitDate": "06-Jan-2026 14:42", + "DischargeDate": null, + "RegCode": "ALHH.0030054832", + "SSN": "2193835069", + "PatientName": "Saleh Aboud Baqays", + "GenderID": "1", + "Gender": "Male", + "FullAge": "38 Year(s)", + "PatientNationality": "YEMEN", + "MobileNo": "0506929720", + "DOB": "25-Aug-1987 00:00", + "ConsultantID": "12933", + "PrimaryDoctor": "18747-MOUTAZ ALI DALATI ", + "CompanyID": "71175", + "GradeID": "4227", + "CompanyName": "Saad Saud Al Arifi endowment // TAWUNIYA", + "GradeName": "GL/B", + "InsuranceCompanyName": "The Cooperative Company for Cooperative Insurance", + "BillType": "CR", + "IsVIP": "0" + }, + { + "Type": "Patient Demographic details", + "PatientID": "2289751", + "AdmissionID": "811959", + "HospitalID": "3", + "HospitalName": "NUZHA", + "PatientTypeID": "2", + "PatientType": "IP", + "AdmitDate": "01-Jan-2026 01:55", + "DischargeDate": "31-Jan-2026 22:22", + "RegCode": "ALHH.0030383875", + "SSN": "1008431296", + "PatientName": "Hamad Ibrahim Allaboun", + "GenderID": "1", + "Gender": "Male", + "FullAge": "80 Year(s)", + "PatientNationality": "SAUDI", + "MobileNo": "0545554828", + "DOB": "12-Jun-1945 00:00", + "ConsultantID": "12461", + "PrimaryDoctor": "18176-ELAWAD KHALID OMER NAFIE ", + "CompanyID": "43814", + "GradeID": "683", + "CompanyName": "SAUDI ARAMCO // BUPA", + "GradeName": "GENERAL CLASS", + "InsuranceCompanyName": "Bupa Arabia for Cooperative Insurance", + "BillType": "CR", + "IsVIP": "0" + } + ], + "FetchPatientDataTimeStampVisitEDDataList": [ + { + "PatientType": "ER", + "Type": "Consultation", + "BillDate": "01-Jan-2026 00:07", + "AdmissionID": "811917", + "PatientID": "2371172", + "RegCode": "ALHH.0040068420", + "SSN": "1220796831", + "MobileNo": "0530112229" + }, { + "PatientType": "ER", + "Type": "Doctor assignment", + "BillDate": "01-Jan-2026 00:11", + "AdmissionID": "811917", + "PatientID": "2371172", + "RegCode": "ALHH.0040068420", + "SSN": "1220796831", + "MobileNo": "0530112229" + }, { + "PatientType": "ER", + "Type": "Admission request", + "BillDate": "01-Jan-2026 00:39", + "AdmissionID": "811917", + "PatientID": "2371172", + "RegCode": "ALHH.0040068420", + "SSN": "1220796831", + "MobileNo": "0530112229" + }, { + "PatientType": "ER", + "Type": "End Of the Episode", + "BillDate": "01-Jan-2026 00:39", + "AdmissionID": "811917", + "PatientID": "2371172", + "RegCode": "ALHH.0040068420", + "SSN": "1220796831", + "MobileNo": "0530112229" + }, { + "PatientType": "ER", + "Type": "Drug Prescription", + "BillDate": "01-Jan-2026 00:21", + "AdmissionID": "811917", + "PatientID": "2371172", + "RegCode": "ALHH.0040068420", + "SSN": "1220796831", + "MobileNo": "0530112229" + } + ], + "FetchPatientDataTimeStampVisitOPDataList": [ + { + "PatientType": "OP", + "Type": "Consultation", + "BillDate": "06-Jan-2026 14:42", + "AdmissionID": "828647", + "PatientID": "1690910", + "RegCode": "ALHH.0030054832", + "SSN": "2193835069", + "MobileNo": "0506929720" + },{ + "PatientType": "OP", + "Type": "Doctor Visited", + "BillDate": "06-Jan-2026 14:42", + "AdmissionID": "828647", + "PatientID": "1690910", + "RegCode": "ALHH.0030054832", + "SSN": "2193835069", + "MobileNo": "0506929720" + },{ + "PatientType": "OP", + "Type": "Rad Prescription", + "BillDate": "06-Jan-2026 15:11", + "AdmissionID": "828647", + "PatientID": "1690910", + "RegCode": "ALHH.0030054832", + "SSN": "2193835069", + "MobileNo": "0506929720" + },{ + "PatientType": "OP", + "Type": "Radiology Bill", + "BillDate": "06-Jan-2026 15:13", + "AdmissionID": "828647", + "PatientID": "1690910", + "RegCode": "ALHH.0030054832", + "SSN": "2193835069", + "MobileNo": "0506929720" + },{ + "PatientType": "OP", + "Type": "Radiology Token", + "BillDate": "06-Jan-2026 15:13", + "AdmissionID": "828647", + "PatientID": "1690910", + "RegCode": "ALHH.0030054832", + "SSN": "2193835069", + "MobileNo": "0506929720" + },{ + "PatientType": "OP", + "Type": "Radiology Patient Arrived", + "BillDate": "06-Jan-2026 15:22", + "AdmissionID": "828647", + "PatientID": "1690910", + "RegCode": "ALHH.0030054832", + "SSN": "2193835069", + "MobileNo": "0506929720" + },{ + "PatientType": "OP", + "Type": "Radiology Examination completed", + "BillDate": "06-Jan-2026 15:27", + "AdmissionID": "828647", + "PatientID": "1690910", + "RegCode": "ALHH.0030054832", + "SSN": "2193835069", + "MobileNo": "0506929720" + } + ], + "FetchPatientDataTimeStampVisitIPDataList": [ + { + "PatientType": "IP", + "Type": "IP Admissions", + "BillDate": "01-Jan-2026 01:55", + "AdmissionID": "811959", + "PatientID": "2289751", + "RegCode": "ALHH.0030383875", + "SSN": "1008431296", + "MobileNo": "0545554828" + },{ + "PatientType": "IP", + "Type": "Bed Allocation", + "BillDate": "01-Jan-2026 01:55", + "AdmissionID": "811959", + "PatientID": "2289751", + "RegCode": "ALHH.0030383875", + "SSN": "1008431296", + "MobileNo": "0545554828" + },{ + "PatientType": "IP", + "Type": "Fit for discharge", + "BillDate": "31-Jan-2026 18:55", + "AdmissionID": "811959", + "PatientID": "2289751", + "RegCode": "ALHH.0030383875", + "SSN": "1008431296", + "MobileNo": "0545554828" + },{ + "PatientType": "IP", + "Type": "Discharge date", + "BillDate": "31-Jan-2026 22:22", + "AdmissionID": "811959", + "PatientID": "2289751", + "RegCode": "ALHH.0030383875", + "SSN": "1008431296", + "MobileNo": "0545554828" + },{ + "PatientType": "IP", + "Type": "Discharge followUp", + "BillDate": "31-Jan-2026 18:55", + "AdmissionID": "811959", + "PatientID": "2289751", + "RegCode": "ALHH.0030383875", + "SSN": "1008431296", + "MobileNo": "0545554828" + }, { + "PatientType": "IP", + "Type": "IP Bill", + "BillDate": "31-Jan-2026 22:22", + "AdmissionID": "811959", + "PatientID": "2289751", + "RegCode": "ALHH.0030383875", + "SSN": "1008431296", + "MobileNo": "0545554828" + } + ], + "Code": 200, + "Status": "Success", + "MobileNo": "", + "Message": "", + "Message2L": "" +} \ No newline at end of file diff --git a/templates/accounts/onboarding/bulk_invite.html b/templates/accounts/onboarding/bulk_invite.html index 1338861..475d55f 100644 --- a/templates/accounts/onboarding/bulk_invite.html +++ b/templates/accounts/onboarding/bulk_invite.html @@ -12,7 +12,7 @@
- + {% trans "Back to Dashboard" %} @@ -81,7 +81,7 @@ {% trans "Send Invitations" %} - + {% trans "Cancel" %}
diff --git a/templates/accounts/onboarding/checklist_list.html b/templates/accounts/onboarding/checklist_list.html index 072cd97..38ce185 100644 --- a/templates/accounts/onboarding/checklist_list.html +++ b/templates/accounts/onboarding/checklist_list.html @@ -63,7 +63,7 @@

{% trans "Manage acknowledgement checklist items" %}

- @@ -196,14 +196,14 @@ - + + {% if location_breakdowns %} +
+

+ + {% trans "Location Breakdown" %} +

+ +
+ {% for loc in location_breakdowns %} +
+

+ {{ loc.location_type }} +

+

{{ loc.complaint_count }}

+

{{ loc.percentage }}%

+
+ {% endfor %} +
+
+ {% endif %} + {% if report.ai_analysis %}
@@ -715,15 +751,26 @@ strokeDashArray: 4 }, annotations: trendData.target ? { - yaxis: [{ - y: trendData.target, - borderColor: '#ef4444', - strokeDashArray: 4, - label: { - text: '{% trans "Target" %}: ' + trendData.target + '%', - style: { color: '#ef4444', background: '#fef2f2', fontSize: '10px' } + yaxis: [ + { + y: trendData.target, + borderColor: '#22c55e', + strokeDashArray: 4, + label: { + text: '{% trans "Target" %}: ' + trendData.target + '%', + style: { color: '#22c55e', background: '#f0fdf4', fontSize: '10px' } + } + }, + { + y: trendData.threshold, + borderColor: '#ef4444', + strokeDashArray: 6, + label: { + text: '{% trans "Threshold" %}: ' + trendData.threshold + '%', + style: { color: '#ef4444', background: '#fef2f2', fontSize: '10px' } + } } - }] + ] } : {}, tooltip: { y: { formatter: function(val) { return val + '%'; } } diff --git a/templates/complaints/complaint_detail.html b/templates/complaints/complaint_detail.html index 2b90c4c..dfc0ddf 100644 --- a/templates/complaints/complaint_detail.html +++ b/templates/complaints/complaint_detail.html @@ -84,6 +84,18 @@

{{ complaint.title }}

+ {% if complaint.ai_brief_en %} +
+ + {{ complaint.ai_brief_en }} + + {% if complaint.ai_brief_ar %} + + {{ complaint.ai_brief_ar }} + + {% endif %} +
+ {% endif %}
{% comment %} @@ -184,7 +196,7 @@
{% endif %} -
+

{% trans "Location" %}

@@ -201,6 +213,12 @@ {{ complaint.get_severity_display }}

+
+

{% trans "Date Created" %}

+

+ {{ complaint.created_at|date:"d M Y, h:i A" }} +

+

{% trans "Response Deadline" %}

@@ -377,6 +395,30 @@

+ + {% if show_delay_reason_closure or complaint.delay_reason_closure %} +
+

+ + {% trans "72h Closure Delay Reason" %} +

+ {% if complaint.delay_reason_closure %} +
+

{{ complaint.delay_reason_closure }}

+
+ {% endif %} + {% if can_edit and complaint.status != "closed" and complaint.status != "resolved" %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+ {% endif %} + {% if complaint.is_activated %}
diff --git a/templates/complaints/complaint_list.html b/templates/complaints/complaint_list.html index 7a8b5c9..82e8454 100644 --- a/templates/complaints/complaint_list.html +++ b/templates/complaints/complaint_list.html @@ -235,11 +235,21 @@ {% endif %} - + {% else %} + + {% endif %} {% if complaint.source %} diff --git a/templates/complaints/emails/explanation_second_reminder_ar.txt b/templates/complaints/emails/explanation_second_reminder_ar.txt new file mode 100644 index 0000000..a80fead --- /dev/null +++ b/templates/complaints/emails/explanation_second_reminder_ar.txt @@ -0,0 +1,25 @@ +تذكير نهائي وعاجل: طلب شرح - شكوى #{{ complaint.id|slice:":8" }} + +عزيزي/عزيزتي {{ staff.first_name_ar }} {{ staff.last_name_ar }}, + +هذا تذكيرك النهائي بأنه تم طلب منك تقديم شرح بخصوص الشكوى التالية: + +رقم الشكوى #{{ complaint.id|slice:":8" }} +العنوان: {{ complaint.title }} +الوصف: {{ complaint.description }} + +موعد تقديم الشرح متبقي {{ hours_remaining }} ساعة (الموعد النهائي: {{ due_date|date:"Y-m-d H:i" }}). + +مهم: إذا لم تقدم شرحك قبل الموعد النهائي، سيتم تصعيد هذا الأمر إلى مديرك المباشر لاتخاذ الإجراء اللازم. + +لإرسال الشرح، يرجى الضغط على الرابط التالي: +{{ site_url }}/complaints/explanation/{{ explanation.token }}/ + +يرجى تقديم الشرح فوراً لتجنب التصعيد. + +إذا كان لديك أي استفسارات، يرجى التواصل مع الشخص الذي طلب هذا الشرح. + +شكراً لتعاونكم. + +--- +هذا تذكير نهائي تلقائي من نظام إدارة الشكاوى PX360 diff --git a/templates/complaints/emails/explanation_second_reminder_en.txt b/templates/complaints/emails/explanation_second_reminder_en.txt new file mode 100644 index 0000000..d38eb54 --- /dev/null +++ b/templates/complaints/emails/explanation_second_reminder_en.txt @@ -0,0 +1,25 @@ +URGENT - FINAL REMINDER: Explanation Request - Complaint #{{ complaint.id|slice:":8" }} + +Dear {{ staff.first_name }} {{ staff.last_name }}, + +This is your FINAL reminder that you have been requested to provide an explanation for the following complaint: + +Complaint #{{ complaint.id|slice:":8" }} +Title: {{ complaint.title }} +Description: {{ complaint.description }} + +Your explanation is due in {{ hours_remaining }} hours (due: {{ due_date|date:"Y-m-d H:i" }}). + +IMPORTANT: If you do not submit your explanation before the deadline, this matter will be escalated to your manager for action. + +To submit your explanation, click on the link below: +{{ site_url }}/complaints/explanation/{{ explanation.token }}/ + +Please submit your explanation immediately to avoid escalation. + +If you have any questions, please contact the person who requested this explanation. + +Thank you for your cooperation. + +--- +This is a FINAL automated reminder from the PX360 Complaint Management System. diff --git a/templates/complaints/partials/explanation_panel.html b/templates/complaints/partials/explanation_panel.html index f3609e2..740a2fd 100644 --- a/templates/complaints/partials/explanation_panel.html +++ b/templates/complaints/partials/explanation_panel.html @@ -2,6 +2,31 @@

{% trans "Staff Explanations" %}

+ {% if complaint.explanation_delay_reason %} +
+
+ +
+

{% trans "Explanation Delay Reason" %}

+

{{ complaint.explanation_delay_reason }}

+
+
+
+ {% endif %} + + {% if can_edit and explanation %} +
+
+ {% csrf_token %} + + + +
+
+ {% endif %} + {% if explanations %}
@@ -109,10 +134,33 @@ {% if can_edit and not exp.is_used %} -
- +
+
+ {% if not exp.reminder_sent_at %} + + {% elif not exp.second_reminder_sent_at %} + + {% else %} + + {% trans "Reminders Sent" %} + + {% endif %} + +
+ {% if exp.reminder_sent_at %} +
+ {% trans "1st reminder:" %} {{ exp.reminder_sent_at|date:"Y-m-d H:i" }} + {% if exp.second_reminder_sent_at %} + {% trans "2nd reminder:" %} {{ exp.second_reminder_sent_at|date:"Y-m-d H:i" }} + {% endif %} +
+ {% endif %}
{% endif %}
@@ -161,9 +209,42 @@
+{% endblock %} diff --git a/templates/complaints/patient_complaint_portal.html b/templates/complaints/patient_complaint_portal.html new file mode 100644 index 0000000..a8d8145 --- /dev/null +++ b/templates/complaints/patient_complaint_portal.html @@ -0,0 +1,105 @@ +{% extends 'layouts/public_base.html' %} +{% load i18n %} + +{% block title %}{% trans "Submit Complaint" %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+ +
+ {% trans "Home" %} +
+ + +
+
+
+ +
+

{% trans "Submit a Complaint" %}

+

{% trans "We're sorry to hear about your experience. Please select the hospital where the incident occurred." %}

+
+
+
+
+ +
+
+

{% trans "Patient" %}

+

{{ patient.get_full_name }}

+
+
+
+
+ + +
+

+ + {% trans "Select Hospital" %} +

+ + {% if hospitals %} + {% for h in hospitals %} + +
+
+
+ +
+
+

{{ h.hospital__name }}

+ {% if h.hospital__name_ar %} +

{{ h.hospital__name_ar }}

+ {% endif %} +
+
+
+ + {{ h.visit_count }} + {% trans "visits" %} + + +
+
+
+ {% endfor %} + {% else %} +
+
+ +
+

{% trans "No visits found for this patient" %}

+
+ {% endif %} +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/complaints/patient_complaint_success.html b/templates/complaints/patient_complaint_success.html new file mode 100644 index 0000000..ec22239 --- /dev/null +++ b/templates/complaints/patient_complaint_success.html @@ -0,0 +1,83 @@ +{% extends 'layouts/public_base.html' %} +{% load i18n %} + +{% block title %}{% trans "Complaint Submitted" %} - PX360{% endblock %} + +{% block content %} +
+
+
+
+ +
+

{% trans "Complaint Submitted" %}

+

{% trans "Thank you for sharing your experience with us" %}

+
+ +
+ {% if complaint.reference_number %} +
+
+
+

{% trans "Reference Number" %}

+

{{ complaint.reference_number }}

+

{% trans "Save this number to track your complaint" %}

+
+
+ {% endif %} + +
+

+ + {% trans "What Happens Next" %} +

+
+
+
+ 1 +
+ {% trans "Your complaint will be reviewed by our patient experience team" %} +
+
+
+ 2 +
+ {% trans "An AI system will analyze and classify your complaint automatically" %} +
+
+
+ 3 +
+ {% trans "A team member may contact you for further details if needed" %} +
+
+
+ 4 +
+ {% trans "You'll receive updates via SMS as your complaint progresses" %} +
+
+
+ + +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/complaints/patient_complaint_visit_form.html b/templates/complaints/patient_complaint_visit_form.html new file mode 100644 index 0000000..1c6ec80 --- /dev/null +++ b/templates/complaints/patient_complaint_visit_form.html @@ -0,0 +1,156 @@ +{% extends 'layouts/public_base.html' %} +{% load i18n %} + +{% block title %}{% trans "Submit Complaint" %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
+ +
+
+
+ {{ visit.patient_type }} + {{ visit.admission_id }} + {% if visit.is_vip %}VIP{% endif %} +
+
+
+
+
+

{% trans "Admission" %}

+

{{ visit.admit_date|date:"d M Y, H:i" }}

+
+
+

{% trans "Doctor" %}

+

{{ visit.doctor_display }}

+
+ {% if visit.discharge_date %} +
+

{% trans "Discharge" %}

+

{{ visit.discharge_date|date:"d M Y, H:i" }}

+
+ {% endif %} +
+
+
+ + +
+
+

+
+ +
+ {% trans "Describe Your Complaint" %} +

+

{% trans "Please provide details about the issue you experienced" %}

+
+ +
+ {% csrf_token %} + + + {% if error %} +
+ + {{ error }} +
+ {% endif %} + +
+ + +
+ + +
+
+ +

+ + {% trans "Your complaint will be reviewed by our team and you'll receive updates via SMS." %} +

+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/complaints/patient_complaint_visits.html b/templates/complaints/patient_complaint_visits.html new file mode 100644 index 0000000..7b17ee9 --- /dev/null +++ b/templates/complaints/patient_complaint_visits.html @@ -0,0 +1,139 @@ +{% extends 'layouts/public_base.html' %} +{% load i18n %} + +{% block title %}{% trans "Select Visit" %} - {{ hospital.name }} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + +
+ +
+ {% trans "Hospitals" %} +
+ + +
+
+
+
+ +
+
+

{{ hospital.name }}

+

{% trans "Select the visit you want to submit a complaint about" %}

+
+
+
+
+
+
+ +
+
+

{% trans "Patient" %}

+

{{ patient.get_full_name }}

+
+
+
+
+ + + +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/complaints/sla_management.html b/templates/complaints/sla_management.html new file mode 100644 index 0000000..8b5203b --- /dev/null +++ b/templates/complaints/sla_management.html @@ -0,0 +1,451 @@ +{% extends 'layouts/base.html' %} +{% load static %} +{% load i18n %} + +{% block title %}{% trans "Complaint SLA Management" %} | {{ hospital.name }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+
+
+

+ + {% trans "Complaint SLA Management" %} +

+

{% trans "Configure Service Level Agreements for complaint resolution" %}

+
+ + + {% trans "Add SLA Configuration" %} + +
+ + +
+
+ +
+

{% trans "Current Hospital" %}

+

{{ hospital.name }}

+
+
+
+
+ + +
+ +
+
+
+
+ +
+
+
+
{% trans "Total Configurations" %}
+

{{ total_configs }}

+
+
+ +
+
+
+ +
+
+
+
{% trans "Source-Based SLAs" %}
+

{{ source_based_configs.count }}

+
+
+ +
+
+
+ +
+
+
+
{% trans "Severity-Based SLAs" %}
+

{{ severity_based_configs.count }}

+
+
+
+ + +
+
+
+ +
+
+

{% trans "SLA Configuration Priority" %}

+
+
    +
  1. {% trans "Source-based configs" %} ({% trans "MOH, CCHI, Patient, etc." %}) {% trans "take precedence" %}
  2. +
  3. {% trans "Severity/Priority-based configs" %} {% trans "apply when no source-based config exists" %}
  4. +
  5. {% trans "System defaults" %} {% trans "are used as fallback" %}
  6. +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+

{% trans "Source-Based SLA Configurations" %}

+

{% trans "SLAs based on complaint source (MOH, CCHI, Patient, etc.)" %}

+
+
+
+ + {% if source_based_configs %} +
+ + + + + + + + + + + + + {% for config in source_based_configs %} + + + + + + + + + {% endfor %} + +
{% trans "Source" %}{% trans "SLA (Hours)" %}{% trans "1st Reminder" %}{% trans "2nd Reminder" %}{% trans "Escalation" %}{% trans "Actions" %}
+
+
+ +
+
+
{{ config.source.name_en }}
+
{{ config.source.name_ar }}
+
+
+
+ + {{ config.sla_hours }}h + + + {% if config.first_reminder_hours_after > 0 %} + +{{ config.first_reminder_hours_after }}h + {% else %} + -{{ config.reminder_hours_before }}h + {% endif %} + + {% if config.second_reminder_hours_after > 0 %} + +{{ config.second_reminder_hours_after }}h + {% elif config.second_reminder_enabled %} + -{{ config.second_reminder_hours_before }}h + {% else %} + + {% endif %} + + {% if config.escalation_hours_after > 0 %} + +{{ config.escalation_hours_after }}h + {% else %} + {% trans "On overdue" %} + {% endif %} + +
+ + + +
+ {% csrf_token %} + +
+
+
+
+ {% else %} +
+
+ +
+

{% trans "No source-based configurations" %}

+

{% trans "Get started by creating a new SLA configuration." %}

+ + + {% trans "Add Source-Based SLA" %} + +
+ {% endif %} +
+ + +
+
+
+
+ +
+
+

{% trans "Severity/Priority-Based SLA Configurations" %}

+

{% trans "SLAs based on complaint severity and priority levels" %}

+
+
+
+ + {% if severity_based_configs %} +
+ + + + + + + + + + + + + {% for config in severity_based_configs %} + + + + + + + + + {% endfor %} + +
{% trans "Severity" %}{% trans "Priority" %}{% trans "SLA (Hours)" %}{% trans "1st Reminder" %}{% trans "2nd Reminder" %}{% trans "Actions" %}
+ {% include 'complaints/partials/severity_badge.html' with severity=config.severity %} + + {% include 'complaints/partials/priority_badge.html' with priority=config.priority %} + + + {{ config.sla_hours }}h + + + {% if config.first_reminder_hours_after > 0 %} + +{{ config.first_reminder_hours_after }}h + {% else %} + -{{ config.reminder_hours_before }}h + {% endif %} + + {% if config.second_reminder_hours_after > 0 %} + +{{ config.second_reminder_hours_after }}h + {% elif config.second_reminder_enabled %} + -{{ config.second_reminder_hours_before }}h + {% else %} + + {% endif %} + +
+ + + +
+ {% csrf_token %} + +
+
+
+
+ {% else %} +
+
+ +
+

{% trans "No severity-based configurations" %}

+

{% trans "Get started by creating a new SLA configuration." %}

+ + + {% trans "Add Severity-Based SLA" %} + +
+ {% endif %} +
+ + +{% endblock %} diff --git a/templates/complaints/sla_management_form.html b/templates/complaints/sla_management_form.html new file mode 100644 index 0000000..544aecd --- /dev/null +++ b/templates/complaints/sla_management_form.html @@ -0,0 +1,381 @@ +{% extends 'layouts/base.html' %} +{% load static %} +{% load i18n %} + +{% block title %}{{ title }} | {{ hospital.name }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+
+
+

+ + {{ title }} +

+

{% trans "Configure SLA parameters for complaint resolution" %}

+
+ + + {% trans "Back to List" %} + +
+
+ + +
+ {% csrf_token %} + + + + + +
+
+

{% trans "Configuration Type" %}

+
+ +
+ +
+
+
+ +
+
+ +
+ {{ form.source }} +
+

+ {% trans "Select a source for source-based SLA, or leave empty for severity/priority-based" %} +

+ {% if form.source.errors %} +

{{ form.source.errors.0 }}

+ {% endif %} +
+
+
+ + +
+
+
+ +
+
+ +
+ {{ form.severity }} + {{ form.priority }} +
+

+ {% trans "Required for severity/priority-based SLA" %} +

+ {% if form.severity.errors %} +

{{ form.severity.errors.0 }}

+ {% endif %} + {% if form.priority.errors %} +

{{ form.priority.errors.0 }}

+ {% endif %} +
+
+
+
+
+ + +
+
+

+ + {% trans "SLA Deadline" %} +

+
+ +
+ +
+ {{ form.sla_hours }} + {% trans "hours" %} +
+

+ {{ form.sla_hours.help_text }} +

+ {% if form.sla_hours.errors %} +

{{ form.sla_hours.errors.0 }}

+ {% endif %} +
+
+ + +
+
+

+ + {% trans "Reminder Configuration" %} +

+
+ +
+ +
+ +
+ {{ form.first_reminder_hours_after }} + {% trans "hours after creation" %} +
+

+ {% trans "Leave as 0 to use legacy timing (hours before deadline)" %} +

+ {% if form.first_reminder_hours_after.errors %} +

{{ form.first_reminder_hours_after.errors.0 }}

+ {% endif %} +
+ + +
+ +
+ {{ form.second_reminder_hours_after }} + {% trans "hours after creation" %} +
+

+ {% trans "Leave as 0 to disable or use legacy timing" %} +

+ {% if form.second_reminder_hours_after.errors %} +

{{ form.second_reminder_hours_after.errors.0 }}

+ {% endif %} +
+
+ + +
+
+ + {% trans "Legacy Timing (hours before deadline)" %} +
+ +
+
+ +
+ {{ form.reminder_hours_before }} + {% trans "hours before" %} +
+
+ +
+
+ {{ form.second_reminder_enabled }} + +
+
+ {{ form.second_reminder_hours_before }} + {% trans "hours before" %} +
+
+
+
+
+ + +
+
+

+ + {% trans "Escalation Configuration" %} +

+
+ +
+
+ +
+ {{ form.escalation_hours_after }} + {% trans "hours after creation" %} +
+

+ {{ form.escalation_hours_after.help_text }} +

+ {% if form.escalation_hours_after.errors %} +

{{ form.escalation_hours_after.errors.0 }}

+ {% endif %} +
+
+ + +
+
+
+

{% trans "Configuration Status" %}

+

{% trans "Enable or disable this SLA configuration" %}

+
+
+ {% trans "Active" %} + {{ form.is_active }} +
+
+
+ + +
+ + {% trans "Cancel" %} + + +
+ + + +{% endblock %} diff --git a/templates/config/dashboard.html b/templates/config/dashboard.html index 4423c64..61ae0ac 100644 --- a/templates/config/dashboard.html +++ b/templates/config/dashboard.html @@ -20,12 +20,25 @@
- + +
+
+ +
+

{% trans "Complaint SLA" %}

+

{% trans "Manage complaint SLA settings" %}

+ + + {% trans "Configure SLA" %} + +
+ +
-

{% trans "SLA Configurations" %}

+

{% trans "General SLA" %}

{{ sla_configs_count }} {% trans "active configs" %}

@@ -84,5 +97,29 @@ {% trans "Manage Call Records" %}
+ + +
+
+ +
+

{% trans "Onboarding" %}

+

{{ provisional_users_count }} {% trans "pending users" %}

+ + + {% trans "Manage Onboarding" %} + +
+{% endblock %} + +{% block extra_js %} + {% endblock %} \ No newline at end of file diff --git a/templates/dashboard/complaint_request_list.html b/templates/dashboard/complaint_request_list.html new file mode 100644 index 0000000..a9cd989 --- /dev/null +++ b/templates/dashboard/complaint_request_list.html @@ -0,0 +1,159 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}Complaint Requests Report - PX360{% endblock %} + +{% block content %} +
+
+
+

Step 0 — Complaint Requests Report

+

Track complaint request filling, status, and timing

+
+ + Export Excel + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Clear +
+
+
+
+ +
+
+ Complaint Requests ({{ page_obj.paginator.count }} total) +
+
+
+ + + + + + + + + + + + + + + + + + {% for req in page_obj %} + + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
#DatePatientFile #DepartmentStaffStatusFill TimeBarcodeNon-Activation ReasonPR Observations
{{ forloop.counter }}{{ req.request_date }}{{ req.patient_name|default:"—" }}{{ req.file_number|default:"—" }}{{ req.complained_department.name|default:"—" }}{{ req.staff.get_full_name|default:"—" }} + {% if req.on_hold %} + On Hold + {% elif req.filled %} + Filled + {% elif req.not_filled %} + Not Filled + {% else %} + + {% endif %} + {{ req.get_filling_time_category_display }}{% if req.from_barcode %}SELF{% else %}—{% endif %}{{ req.get_reason_non_activation_display|default:"—" }}{{ req.pr_observations|default:"—" }}
+ No complaint requests found for the selected filters. +
+
+
+ {% if page_obj.has_other_pages %} + + {% endif %} +
+
+{% endblock %} diff --git a/templates/dashboard/employee_evaluation.html b/templates/dashboard/employee_evaluation.html new file mode 100644 index 0000000..bbee526 --- /dev/null +++ b/templates/dashboard/employee_evaluation.html @@ -0,0 +1,2317 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Employee Evaluation" %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + +
+
+
+

+ + {% trans "PAD Department – Patients Relations Weekly Dashboard" %} +

+

+ {% trans "From:" %} {{ evaluation_data.start_date|date:"d M Y" }} + – {% trans "To:" %} {{ evaluation_data.end_date|date:"d M Y" }} +

+
+
+

{% trans "Last Updated" %}

+

{% now "j M Y, H:i" %}

+
+
+
+ + +
+
+ +

{% trans "Filters" %}

+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + + + +
+
+
+ + {% if evaluation_data.staff_metrics %} + +
+
+
+ +

{% trans "Performance Trends (Last 4 Weeks)" %}

+
+ +
+
+
+ + +
+ +
+
+
+

{% trans "Total Complaints" %}

+

{{ evaluation_data.summary.total_complaints }}

+
+
+ +
+
+
+ + +
+
+
+

{% trans "Total Inquiries" %}

+

{{ evaluation_data.summary.total_inquiries }}

+
+
+ +
+
+
+ + +
+
+
+

{% trans "Total Notes" %}

+

{{ evaluation_data.summary.total_notes }}

+
+
+ +
+
+
+ + +
+
+
+

{% trans "Total Escalated" %}

+

{{ evaluation_data.summary.total_escalated }}

+
+
+ +
+
+
+
+ + +
+ {% for staff in evaluation_data.staff_metrics %} +
+ +
+
{{ staff.name }}
+
+ {% if staff.department %}{{ staff.department }}{% endif %} + {% if staff.hospital %} | {{ staff.hospital }}{% endif %} +
+
+ + +
+
+
+ + {% trans "Complaints by Response Time" %} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "24h" %}{% trans "48h" %}{% trans "72h" %}{% trans ">72h" %}{% trans "Total" %}
{{ staff.complaints_response_time.24h }}{{ staff.complaints_response_time.48h }}{{ staff.complaints_response_time.72h }}{{ staff.complaints_response_time.more_than_72h }}{{ staff.complaints_response_time.total }}
{{ staff.complaints_response_time.percentages.24h }}%{{ staff.complaints_response_time.percentages.48h }}%{{ staff.complaints_response_time.percentages.72h }}%{{ staff.complaints_response_time.percentages.more_than_72h }}%100%
+
+
+
+ + +
+
+
+ + {% trans "Complaint Source Breakdown" %} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Source" %}{% trans "Count" %}{% trans "%" %}
{% trans "MOH" %}{{ staff.complaint_sources.counts.MOH }}{{ staff.complaint_sources.percentages.MOH }}%
{% trans "CCHI" %}{{ staff.complaint_sources.counts.CCHI }}{{ staff.complaint_sources.percentages.CCHI }}%
{% trans "Patients" %}{{ staff.complaint_sources.counts.Patients }}{{ staff.complaint_sources.percentages.Patients }}%
{% trans "Patient's relatives" %}{{ staff.complaint_sources.counts.Patient_relatives }}{{ staff.complaint_sources.percentages.Patient_relatives }}%
{% trans "Insurance company" %}{{ staff.complaint_sources.counts.Insurance_company }}{{ staff.complaint_sources.percentages.Insurance_company }}%
{% trans "Total" %}{{ staff.complaint_sources.total }}100%
+
+
+
+ + +
+
+
+ + {% trans "Response Time by Source (CHI vs MOH)" %} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Time" %}{% trans "CHI" %}{% trans "MOH" %}
{% trans "24 Hours" %}{{ staff.response_time_by_source.24h.CHI }}{{ staff.response_time_by_source.24h.MOH }}
{% trans "48 Hours" %}{{ staff.response_time_by_source.48h.CHI }}{{ staff.response_time_by_source.48h.MOH }}
{% trans "72 Hours" %}{{ staff.response_time_by_source.72h.CHI }}{{ staff.response_time_by_source.72h.MOH }}
{% trans ">72 Hours" %}{{ staff.response_time_by_source.more_than_72h.CHI }}{{ staff.response_time_by_source.more_than_72h.MOH }}
{% trans "Total" %}{{ staff.response_time_by_source.totals.CHI }}{{ staff.response_time_by_source.totals.MOH }}
+
+
+
+ + +
+
+
+ + {% trans "Patient Type Breakdown" %} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Type" %}{% trans "Count" %}{% trans "%" %}
{% trans "In-Patient" %}{{ staff.patient_type_breakdown.counts.In_Patient }}{{ staff.patient_type_breakdown.percentages.In_Patient }}%
{% trans "Out-Patient" %}{{ staff.patient_type_breakdown.counts.Out_Patient }}{{ staff.patient_type_breakdown.percentages.Out_Patient }}%
{% trans "ER" %}{{ staff.patient_type_breakdown.counts.ER }}{{ staff.patient_type_breakdown.percentages.ER }}%
{% trans "Total" %}{{ staff.patient_type_breakdown.total }}100%
+
+
+
+ + +
+
+
+ + {% trans "Department Type Breakdown" %} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Department" %}{% trans "Count" %}{% trans "%" %}
{% trans "Medical" %}{{ staff.department_type_breakdown.counts.Medical }}{{ staff.department_type_breakdown.percentages.Medical }}%
{% trans "Admin" %}{{ staff.department_type_breakdown.counts.Admin }}{{ staff.department_type_breakdown.percentages.Admin }}%
{% trans "Nursing" %}{{ staff.department_type_breakdown.counts.Nursing }}{{ staff.department_type_breakdown.percentages.Nursing }}%
{% trans "Support Services" %}{{ staff.department_type_breakdown.counts.Support_Services }}{{ staff.department_type_breakdown.percentages.Support_Services }}%
{% trans "Total" %}{{ staff.department_type_breakdown.total }}100%
+
+
+
+ + +
+
+
+ + {% trans "Delays and Activation" %} +
+ +
+
+
+
+
{{ staff.delays_activation.delays }}
+
{% trans "Delays" %}
+
+ {{ staff.delays_activation.percentages.delays }}% +
+
+
+
{{ staff.delays_activation.activated_within_2h }}
+
{% trans "Activated ≤2h" %}
+
+ {{ staff.delays_activation.percentages.activated }}% +
+
+
+
+
+ + +
+
+
+ + {% trans "Escalated Complaints" %} +
+ +
+
+ + + + + + + + + + + + + + + + + +
{% trans "Before 72h" %}{% trans "Exactly 72h" %}{% trans "After 72h" %}{% trans "Resolved" %}
{{ staff.escalated_complaints.before_72h }}{{ staff.escalated_complaints.exactly_72h }}{{ staff.escalated_complaints.after_72h }}{{ staff.escalated_complaints.resolved }}
+
+ + {% trans "Total Escalated:" %} {{ staff.escalated_complaints.total_escalated }} + +
+
+
+
+ + +
+
+
+ + {% trans "Inquiries" %} +
+ +
+
+ +
+

{% trans "Incoming" %} ({{ staff.inquiries.incoming.total }})

+ + + + + + + + + + + + + + + + + +
24h48h72h>72h
{{ staff.inquiries.incoming.by_time.24h }}{{ staff.inquiries.incoming.by_time.48h }}{{ staff.inquiries.incoming.by_time.72h }}{{ staff.inquiries.incoming.by_time.more_than_72h }}
+
+ {% trans "تحت الإجراء:" %} {{ staff.inquiries.incoming.by_status.in_progress }} + {% trans "تم التواصل:" %} {{ staff.inquiries.incoming.by_status.contacted }} + {% trans "لم يتم الرد:" %} {{ staff.inquiries.incoming.by_status.contacted_no_response }} +
+
+ + +
+

{% trans "Outgoing" %} ({{ staff.inquiries.outgoing.total }})

+ + + + + + + + + + + + + + + + + +
24h48h72h>72h
{{ staff.inquiries.outgoing.by_time.24h }}{{ staff.inquiries.outgoing.by_time.48h }}{{ staff.inquiries.outgoing.by_time.72h }}{{ staff.inquiries.outgoing.by_time.more_than_72h }}
+
+ {% trans "تحت الإجراء:" %} {{ staff.inquiries.outgoing.by_status.in_progress }} + {% trans "تم التواصل:" %} {{ staff.inquiries.outgoing.by_status.contacted }} + {% trans "لم يتم الرد:" %} {{ staff.inquiries.outgoing.by_status.contacted_no_response }} +
+
+
+
+
+ + +
+
+
+ + {% trans "Notes" %} +
+ +
+
+
+ {{ staff.notes.total }} + {% trans "Total Notes" %} +
+ {% for cat_key, cat_data in staff.notes.by_category.items %} +
+

{{ cat_data.name }} ({{ cat_data.total }})

+
+ {% for sub_key, sub_data in cat_data.subcategories.items %} +
+ {{ sub_data.name }} + {{ sub_data.count }} +
+ {% endfor %} +
+
+ {% endfor %} +
+
+ + +
+
+
+ + {% trans "Complaint Request & Filling" %} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Status" %}{% trans "Count" %}{% trans "%" %}
{% trans "Filled" %}{{ staff.complaint_requests.filled }}{{ staff.complaint_requests.percentages.filled }}%
{% trans "Not Filled" %}{{ staff.complaint_requests.not_filled }}{{ staff.complaint_requests.percentages.not_filled }}%
{% trans "On Hold" %}{{ staff.complaint_requests.on_hold }}{{ staff.complaint_requests.percentages.on_hold }}%
{% trans "From Barcode" %}{{ staff.complaint_requests.from_barcode }}-
+
+ {% trans "Total:" %} {{ staff.complaint_requests.total }} +
+
+
+
+ + +
+
+
+ + {% trans "Report Completion Tracker" %} +
+ +
+
+
+
+ {{ staff.report_completion.completion_percentage }}% + + {{ staff.report_completion.completed_count }}/{{ staff.report_completion.total_reports }} + +
+
+
+
+
+
+ {% for report in staff.report_completion.reports %} +
+
+ +
+ + {{ report.name }} + +
+ {% endfor %} +
+
+
+
+ {% endfor %} +
+ + {% if evaluation_data.staff_metrics %} + +
+
+
+ +

{% trans "Comparison Table" %}

+
+ +
+ +
+ + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + + + + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + + + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + + + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + + + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + + + {% for staff in evaluation_data.staff_metrics %} + + {% endfor %} + + +
{% trans "Metric" %}{{ staff.name }}
+ {% trans "RESPONSE TIME" %} +
{% trans "24h Response Rate" %}{{ staff.complaints_response_time.percentages.24h }}%
{% trans "48h Response Rate" %}{{ staff.complaints_response_time.percentages.48h }}%
{% trans ">72h Overdue Rate" %}{{ staff.complaints_response_time.percentages.more_than_72h }}%
+ {% trans "COMPLAINTS" %} +
{% trans "Total Complaints" %}{{ staff.complaints_response_time.total }}
{% trans "MOH Complaints" %}{{ staff.complaint_sources.counts.MOH }}
{% trans "CCHI Complaints" %}{{ staff.complaint_sources.counts.CCHI }}
{% trans "Patient Complaints" %}{{ staff.complaint_sources.counts.Patients }}
+ {% trans "PERFORMANCE" %} +
{% trans "Delay Rate" %}{{ staff.delays_activation.percentages.delays }}%
{% trans "Activation Rate" %}{{ staff.delays_activation.percentages.activated }}%
+ {% trans "OTHER" %} +
{% trans "Total Escalated" %}{{ staff.escalated_complaints.total_escalated }}
{% trans "Total Inquiries" %}{{ staff.inquiries.total }}
{% trans "Total Notes" %}{{ staff.notes.total }}
{% trans "Report Completion" %}{{ staff.report_completion.completion_percentage }}%
+
+ +
+
+ + {% trans "Best performer" %} + + + {% trans "Needs improvement" %} + +
+ {% trans "Based on selected comparison criteria" %} +
+
+ {% endif %} + + {% else %} + +
+
+ +
+

{% trans "No Data Available" %}

+

+ {% trans "No staff members with assigned complaints or inquiries found in the selected time period." %} +

+
+ {% endif %} + +
+{% endblock %} + +{% block extra_js %} + +{{ evaluation_data|json_script:"evaluationData" }} + +{% endblock %} \ No newline at end of file diff --git a/templates/feedback/action_plan_list.html b/templates/feedback/action_plan_list.html new file mode 100644 index 0000000..ebe0ca5 --- /dev/null +++ b/templates/feedback/action_plan_list.html @@ -0,0 +1,95 @@ +{% extends "layouts/base.html" %} + +{% block title %}Comment Action Plans - PX360{% endblock %} + +{% block content %} +
+
+
+

Steps 3-5 — Comment Action Plans

+

Track action plans derived from patient comments

+
+ + Export Action Plans + +
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + {% regroup action_plans by department_label as dept_groups %} + {% for dept, plans in dept_groups %} +
+
+ {{ dept }} + {{ plans|length }} plans +
+
+ + + + + + + + + + + + + + + {% for plan in plans %} + + + + + + + + + + + {% endfor %} + +
#CommentFreqRecommendationResponsible DeptTimeframeStatusEvidences
{{ plan.problem_number }}{{ plan.comment_text }}{{ plan.frequency }}{{ plan.recommendation }}{{ plan.responsible_department }}{{ plan.timeframe }} + {% if plan.status == 'completed' %}Completed + {% elif plan.status == 'on_process' %}On Process + {% else %}Pending{% endif %} + {{ plan.evidences }}
+
+
+ {% empty %} +
+
No action plans found.
+
+ {% endfor %} +
+{% endblock %} diff --git a/templates/feedback/comment_import_list.html b/templates/feedback/comment_import_list.html new file mode 100644 index 0000000..74e6fdd --- /dev/null +++ b/templates/feedback/comment_import_list.html @@ -0,0 +1,57 @@ +{% extends "layouts/base.html" %} + +{% block title %}Comment Imports - PX360{% endblock %} + +{% block content %} +
+

Step 0 — Comment Imports

+

Monthly patient comment data imports from IT department

+ +
+
+ + + + + + + + + + + + + + + + {% for imp in imports %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
HospitalYearMonthStatusTotal RowsImportedErrorsImported ByDate
{{ imp.hospital }}{{ imp.year }}{{ imp.month }} + {% if imp.status == 'completed' %} + Completed + {% elif imp.status == 'failed' %} + Failed + {% elif imp.status == 'processing' %} + Processing + {% else %} + Pending + {% endif %} + {{ imp.total_rows }}{{ imp.imported_count }}{{ imp.error_count }}{{ imp.imported_by.get_full_name|default:"—" }}{{ imp.created_at|date:"Y-m-d H:i" }}
No imports yet.
+
+
+
+{% endblock %} diff --git a/templates/feedback/comment_list.html b/templates/feedback/comment_list.html new file mode 100644 index 0000000..665ed83 --- /dev/null +++ b/templates/feedback/comment_list.html @@ -0,0 +1,152 @@ +{% extends "layouts/base.html" %} + +{% block title %}Patient Comments - PX360{% endblock %} + +{% block content %} +
+
+
+

Step 1 — Classified Patient Comments

+

Classified comments with categories and sentiment

+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+ Comments ({{ page_obj.paginator.count }} total) +
+
+
+ + + + + + + + + + + + + + + {% for c in page_obj %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
#SourceCommentClassificationSub-CategorySentimentNegativePositive
{{ c.serial_number }}{{ c.get_source_category_display }}{{ c.comment_text }}{{ c.get_classification_display }}{{ c.get_sub_category_display }} + {% if c.sentiment == 'negative' %}{% elif c.sentiment == 'positive' %}{% elif c.sentiment == 'neutral' %}{% else %}{% endif %}{{ c.get_sentiment_display }} + {{ c.negative_keywords }}{{ c.positive_keywords }}
No comments found.
+
+
+ {% if page_obj.has_other_pages %} + + {% endif %} +
+
+{% endblock %} diff --git a/templates/integrations/survey_mapping_settings.html b/templates/integrations/survey_mapping_settings.html index 6a9a93a..a8be015 100644 --- a/templates/integrations/survey_mapping_settings.html +++ b/templates/integrations/survey_mapping_settings.html @@ -148,10 +148,10 @@
diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 24ce455..07fab09 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -215,6 +215,100 @@ } } }); + + // Notification dropdown handler + document.addEventListener('click', function(e) { + const notificationBtn = e.target.closest('[onclick*="notificationDropdown"]'); + if (notificationBtn) { + loadNotifications(); + } + }); + + // Load notifications for dropdown + async function loadNotifications() { + try { + const response = await fetch('/notifications/api/latest/'); + const data = await response.json(); + const container = document.getElementById('notificationDropdownContent'); + + if (!container) return; + + if (data.notifications.length === 0) { + container.innerHTML = ` +
+
+ +
+

{% trans "No new notifications" %}

+

{% trans "You're all caught up!" %}

+
+ `; + } else { + container.innerHTML = data.notifications.map(n => { + const icon = n.type === 'complaint_assigned' ? 'user-check' : + n.type === 'sla_reminder' ? 'clock' : 'bell'; + return ` + +
+
+ + ${icon === 'user-check' ? '' : + icon === 'clock' ? '' : + ''} + +
+
+

${n.title}

+

${formatTime(n.created_at)}

+
+
+
+ `}).join(''); + + if (data.has_more) { + container.innerHTML += ` + + {% trans "View all notifications" %} + + `; + } + } + } catch (error) { + console.error('Error loading notifications:', error); + } + } + + // Format timestamp to relative time + function formatTime(isoString) { + const date = new Date(isoString); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 60) return '{% trans "Just now" %}'; + if (diff < 3600) return Math.floor(diff / 60) + ' {% trans "min ago" %}'; + if (diff < 86400) return Math.floor(diff / 3600) + ' {% trans "hours ago" %}'; + return Math.floor(diff / 86400) + ' {% trans "days ago" %}'; + } + + // Poll for new notifications every 30 seconds + setInterval(async () => { + try { + const response = await fetch('/notifications/api/unread-count/'); + const data = await response.json(); + const badge = document.getElementById('notification-count-badge'); + + if (badge) { + if (data.count > 0) { + badge.textContent = data.count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + } + } catch (error) { + console.error('Error polling notifications:', error); + } + }, 30000); {% block extra_js %}{% endblock %} diff --git a/templates/layouts/partials/sidebar.html b/templates/layouts/partials/sidebar.html index 13993f2..c2d5e4d 100644 --- a/templates/layouts/partials/sidebar.html +++ b/templates/layouts/partials/sidebar.html @@ -118,14 +118,29 @@ {% trans "Dashboard" %} - + {% if user.is_px_admin or user.is_hospital_admin %} {% if not user.source_user_profile %} - - - {% trans "Admin Evaluation" %} - + {% endif %} {% endif %} @@ -216,17 +231,17 @@ {% trans "Survey List" %} - + {% trans "Templates" %} - + {% trans "Create Survey" %} - {% trans "Manual Send" %} @@ -290,11 +305,20 @@ {% endif %} - {% comment %} {% trans "Patients" %} - {% endcomment %} + + + + {% if user.is_px_admin %} + + + {% trans "Social Media" %} + + {% endif %} {% if not user.source_user_profile %} @@ -700,6 +724,14 @@ function toggleStandardsMenu(event) { chevron.classList.toggle('rotate-180'); } +function togglePerformanceMenu(event) { + event.preventDefault(); + const submenu = document.getElementById('performance-submenu'); + const chevron = document.getElementById('performance-chevron'); + submenu.classList.toggle('open'); + chevron.classList.toggle('rotate-180'); +} + function logout() { if (confirm('{% trans "Are you sure you want to logout?" %}')) { document.querySelector('form[action="{% url 'accounts:logout' %}"]').submit(); diff --git a/templates/layouts/partials/topbar.html b/templates/layouts/partials/topbar.html index 0061cba..5918e01 100644 --- a/templates/layouts/partials/topbar.html +++ b/templates/layouts/partials/topbar.html @@ -37,49 +37,27 @@ onclick="document.getElementById('notificationDropdown').classList.toggle('hidden')" class="p-2 text-gray-400 hover:bg-gray-100 rounded-full transition relative"> - {% if notification_count|default:0 > 0 %} - + {% if notification_count > 0 %} + + {{ notification_count }} + + {% else %} + {% endif %} diff --git a/templates/notifications/inbox.html b/templates/notifications/inbox.html new file mode 100644 index 0000000..59ce446 --- /dev/null +++ b/templates/notifications/inbox.html @@ -0,0 +1,320 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} +{% load static %} + +{% block title %}{% trans "Notifications" %} - PX360{% endblock %} + +{% block content %} + +
+
+
+

+ + {% trans "Notifications" %} +

+

{% trans "View and manage your notifications" %}

+
+
+ {% if unread_count > 0 %} + + {% endif %} + {% if notifications %} + + {% endif %} +
+
+
+ + + + + +{% if notifications %} +
+ {% for notification in notifications %} +
+
+ +
+ +
+ + +
+
+
+

+ {{ notification.get_title }} +

+

{{ notification.get_message }}

+
+ {{ notification.created_at|timesince }} {% trans "ago" %} + {% if not notification.is_read %} + {% trans "New" %} + {% endif %} +
+
+ + +
+ {% if not notification.is_read %} + + {% endif %} + +
+
+
+
+
+ {% endfor %} +
+ + +{% if notifications.has_other_pages %} +
+ {% if notifications.has_previous %} + + + + {% endif %} + + + {{ notifications.number }} / {{ notifications.paginator.num_pages }} + + + {% if notifications.has_next %} + + + + {% endif %} +
+{% endif %} + +{% else %} + +
+
+ +
+

+ {% if filter == 'unread' %} + {% trans "No unread notifications" %} + {% elif filter == 'read' %} + {% trans "No read notifications" %} + {% else %} + {% trans "No notifications" %} + {% endif %} +

+

+ {% if filter == 'unread' %} + {% trans "You're all caught up!" %} + {% else %} + {% trans "You don't have any notifications yet." %} + {% endif %} +

+
+{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/organizations/patient_detail.html b/templates/organizations/patient_detail.html index 455499e..fbaae4b 100644 --- a/templates/organizations/patient_detail.html +++ b/templates/organizations/patient_detail.html @@ -26,22 +26,6 @@ color: #1e293b; line-height: 1.5; } - .field-value-secondary { - font-size: 13px; - color: #64748b; - } - .survey-item { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - border-left: 3px solid transparent; - } - .survey-item:hover { - border-left-color: #007bbd; - background: linear-gradient(135deg, #eef6fb 0%, #f0f9ff 100%); - transform: translateX(4px); - } - .metric-card { - background: linear-gradient(135deg, #007bbd 0%, #005696 100%); - } .avatar-circle { background: linear-gradient(135deg, #007bbd 0%, #005696 100%); box-shadow: 0 4px 6px -1px rgba(0, 123, 189, 0.4); @@ -59,283 +43,624 @@ .icon-wrapper { box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.1); } + .tab-btn { + padding: 0.625rem 1.25rem; + border-radius: 0.75rem; + font-size: 0.8125rem; + font-weight: 600; + transition: all 0.2s; + border: 2px solid transparent; + display: inline-flex; + align-items: center; + gap: 0.5rem; + } + .tab-btn.active { + background: #005696; + color: white; + border-color: #005696; + } + .tab-btn:not(.active) { + color: #64748b; + border-color: #e2e8f0; + background: white; + } + .tab-btn:not(.active):hover { + border-color: #005696; + color: #005696; + background: #f8fafc; + } + .tab-btn .tab-count { + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 700; + } + .tab-btn.active .tab-count { + background: rgba(255,255,255,0.2); + color: white; + } + .tab-btn:not(.active) .tab-count { + background: #f1f5f9; + color: #64748b; + } + .data-row { + transition: all 0.2s ease; + border-left: 3px solid transparent; + } + .data-row:hover { + background: linear-gradient(135deg, #eef6fb 0%, #f0f9ff 100%); + border-left-color: #007bbd; + } + .type-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.625rem; + border-radius: 0.5rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.025em; + } + .type-badge.ed { background: #fef3c7; color: #92400e; } + .type-badge.ip { background: #dbeafe; color: #1e40af; } + .type-badge.op { background: #d1fae5; color: #065f46; } + .priority-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + } + .vip-badge { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.625rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + } {% endblock %} {% block content %}
- -
- - - - -
-
- -
- {{ patient.first_name|first }}{{ patient.last_name|first }} -
- - -
-

- {{ patient.get_full_name }} - {% if patient.first_name_ar or patient.last_name_ar %} - - {{ patient.first_name_ar|default:'' }} {{ patient.last_name_ar|default:'' }} - - {% endif %} -

- -
- - - {% trans "MRN" %}: - {{ patient.mrn }} - - {% if patient.national_id %} - - - {% trans "National ID" %}: - {{ patient.national_id }} - - {% endif %} - {% if patient.date_of_birth %} - - - {% trans "DOB" %}: - {{ patient.date_of_birth }} - - {% endif %} -
-
-
- - -
- - - {% trans "Edit Patient" %} - - {% if request.user.is_px_admin %} - - - {% trans "Delete" %} - - {% endif %} -
-
-
- -
- -
- - -
-

-
- -
- {% trans "Basic Information" %} -

- -
-
-

{% trans "First Name" %}

-

{{ patient.first_name|default:"-" }}

- {% if patient.first_name_ar %} -

{{ patient.first_name_ar }}

- {% endif %} -
-
-

{% trans "Last Name" %}

-

{{ patient.last_name|default:"-" }}

- {% if patient.last_name_ar %} -

{{ patient.last_name_ar }}

- {% endif %} -
-
-

{% trans "Date of Birth" %}

-

{{ patient.date_of_birth|default:"-" }}

-
-
-

{% trans "Gender" %}

-

{{ patient.get_gender_display|default:"-" }}

-
-
-

{% trans "National ID" %}

-

{{ patient.national_id|default:"-" }}

-
-
-

{% trans "Status" %}

- - {{ patient.get_status_display }} - -
-
-
- - -
-

-
- -
- {% trans "Contact Information" %} -

- -
-
-

{% trans "Phone" %}

-

{{ patient.phone|default:"-" }}

-
-
-

{% trans "Email" %}

-

{{ patient.email|default:"-" }}

-
-
-

{% trans "Address" %}

-

{{ patient.address|default:"-" }}

-
-
-

{% trans "City" %}

-

{{ patient.city|default:"-" }}

-
-
-
- - - {% if surveys %} -
-

-
- -
- {% trans "Recent Surveys" %} - {{ surveys.count }} -

- -
- {% for survey in surveys %} -
-
-

{{ survey.survey_template.name }}

-
- - - {{ survey.created_at|date:"d M Y" }} - - - - {{ survey.created_at|date:"H:i" }} - -
-
-
- {% if survey.total_score %} -
- - {{ survey.total_score }} - - {% trans "Score" %} -
- {% endif %} - +
-
- {% endfor %} -
+ {{ patient.get_status_display }} + + -
- - {% trans "View all surveys" %} - - +
+
+
+ {{ patient.first_name|first }}{{ patient.last_name|first }} +
+
+

+ {{ patient.get_full_name }} + {% if patient.first_name_ar or patient.last_name_ar %} + + {{ patient.first_name_ar|default:'' }} {{ patient.last_name_ar|default:'' }} + + {% endif %} +

+
+ + + {% trans "MRN" %}: + {{ patient.mrn }} + + {% if patient.national_id %} + + + {% trans "SSN" %}: + {{ patient.national_id }} + + {% endif %} + {% if patient.phone %} + + + {{ patient.phone }} + + {% endif %} + {% if patient.nationality %} + + + {{ patient.nationality }} + + {% endif %} +
+
+
+
+ + + + {% trans "Edit" %} + + {% if request.user.is_px_admin %} + + + {% trans "Delete" %} + + {% endif %} +
-
- {% endif %} -
+ - -
- - -
-
-
-
-
-
- -
-

{% trans "Hospital" %}

-
-

{{ patient.primary_hospital.name|default:"-" }}

- {% if patient.primary_hospital.name_ar %} -

{{ patient.primary_hospital.name_ar }}

- {% endif %} -
-
+
+
+
+

+
+ +
+ {% trans "Patient Information" %} +

+
+
+

{% trans "First Name" %}

+

{{ patient.first_name|default:"-" }}

+ {% if patient.first_name_ar %}

{{ patient.first_name_ar }}

{% endif %} +
+
+

{% trans "Last Name" %}

+

{{ patient.last_name|default:"-" }}

+ {% if patient.last_name_ar %}

{{ patient.last_name_ar }}

{% endif %} +
+
+

{% trans "Gender" %}

+

{{ patient.get_gender_display|default:"-" }}

+
+
+

{% trans "Date of Birth" %}

+

{{ patient.date_of_birth|default:"-" }}

+
+
+

{% trans "Nationality" %}

+

{{ patient.nationality|default:"-" }}

+
+
+

{% trans "Phone" %}

+

{{ patient.phone|default:"-" }}

+
+
+

{% trans "Email" %}

+

{{ patient.email|default:"-" }}

+
+
+

{% trans "City" %}

+

{{ patient.city|default:"-" }}

+
+
+

{% trans "Status" %}

+ + {{ patient.get_status_display }} + +
+
+
- -
-
-
- -
-

{% trans "Metadata" %}

+
+ + +
+ {% if tab == 'visits' %} +
+ + + + + + + + + + + + + + {% for visit in his_visits %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Adm ID" %}{% trans "Type" %}{% trans "Dates" %}{% trans "Doctor" %}{% trans "Insurance" %}{% trans "Status" %}{% trans "Journey" %}
+ {{ visit.admission_id }} + +
+ {{ visit.patient_type }} + {% if visit.is_vip %}VIP{% endif %} +
+
+
+
{{ visit.admit_date|date:"d M Y H:i"|default:"-" }}
+ {% if visit.discharge_date %} +
+ + {{ visit.discharge_date|date:"d M Y H:i" }} +
+ {% else %} + {% trans "In Progress" %} + {% endif %} +
+
+ {% if visit.primary_doctor_fk %} + + {{ visit.primary_doctor_fk }} + + {% else %} + {{ visit.doctor_display }} + {% endif %} + + {% if visit.insurance_company_name %} + {{ visit.insurance_company_name }} + {% else %} + - + {% endif %} + {% if visit.bill_type %} + {{ visit.bill_type }} + {% endif %} + + {% if visit.is_visit_complete %} + {% trans "Complete" %} + {% elif visit.survey_instance %} + {% trans "Survey Sent" %} + {% else %} + {% trans "Active" %} + {% endif %} + + + + {% trans "View" %} + +
+
+
+ +
+

{% trans "No HIS visits found" %}

+
+
+
+ + {% elif tab == 'surveys' %} +
+ {% for survey in surveys %} +
+
+

{{ survey.survey_template.name }}

+
+ {% if survey.sent_at %} + + + {{ survey.sent_at|date:"d M Y H:i" }} + + {% endif %} + {% if survey.completed_at %} + + + {{ survey.completed_at|date:"d M Y H:i" }} + + {% endif %} + {% if survey.delivery_channel %} + + + {{ survey.delivery_channel|upper }} + + {% endif %} +
+
+
+ {% if survey.total_score %} +
+ + {{ survey.total_score }} + + {% trans "Score" %} +
+ {% endif %} + + {{ survey.get_status_display }} + +
+
+ {% empty %} +
+
+ +
+

{% trans "No surveys found" %}

+
+ {% endfor %} +
+ + {% elif tab == 'complaints' %} +
+ {% for complaint in complaints %} +
+
+
+ {{ complaint.reference_number|default:"-" }} + {% if complaint.priority %} + + {% endif %} +
+

{{ complaint.title }}

+
+ + + {{ complaint.created_at|date:"d M Y" }} + + {% if complaint.department %} + + + {{ complaint.department.name }} + + {% endif %} +
+
+ + {{ complaint.get_status_display }} + +
+ {% empty %} +
+
+ +
+

{% trans "No complaints found" %}

+
+ {% endfor %} +
+ + {% elif tab == 'inquiries' %} +
+ {% for inquiry in inquiries %} +
+
+

{{ inquiry.subject }}

+
+ + + {{ inquiry.created_at|date:"d M Y" }} + + {{ inquiry.category }} + {% if inquiry.department %} + + + {{ inquiry.department.name }} + + {% endif %} +
+
+ + {{ inquiry.get_status_display }} + +
+ {% empty %} +
+
+ +
+

{% trans "No inquiries found" %}

+
+ {% endfor %} +
+ {% endif %} +
+
-
-
-
- - {% trans "Created" %} -
- {{ patient.created_at|date:"d M Y" }} -
-
-
- - {% trans "Updated" %} -
- {{ patient.updated_at|date:"d M Y" }} -
+ +
+
+

+
+ +
+ {% trans "Summary" %} +

+ +
+ +
+
+
+
+
+
+ +
+

{% trans "Hospital" %}

+
+

{{ patient.primary_hospital.name|default:"-" }}

+ {% if patient.primary_hospital.name_ar %} +

{{ patient.primary_hospital.name_ar }}

+ {% endif %} +
+
+ +
+
+
+ +
+

{% trans "Record Info" %}

+
+
+
+
+ + {% trans "Created" %} +
+ {{ patient.created_at|date:"d M Y H:i" }} +
+
+
+ + {% trans "Updated" %} +
+ {{ patient.updated_at|date:"d M Y H:i" }} +
+
+
-
-
{% endblock %} {% block extra_js %} {% endblock %} diff --git a/templates/organizations/patient_list.html b/templates/organizations/patient_list.html index 9a511de..79de201 100644 --- a/templates/organizations/patient_list.html +++ b/templates/organizations/patient_list.html @@ -6,7 +6,6 @@ {% block extra_css %} {% endblock %} {% block content %} +{% csrf_token %}
-

{% trans "Patients" %}

{% trans "Manage patient records and information" %}

- {% if request.user.is_px_admin or request.user.is_hospital_admin %} - - {% trans "Add Patient" %} - - {% endif %} +
+ + + {% trans "Export CSV" %} + + {% if request.user.is_px_admin or request.user.is_hospital_admin %} + + {% trans "Add Patient" %} + + {% endif %} +
-
@@ -138,7 +150,7 @@
{% trans "Total Patients" %}
-

{{ page_obj.paginator.count|default:0 }}

+

{{ stats.total|default:0 }}

@@ -151,7 +163,7 @@
{% trans "Active" %}
-

{{ page_obj.paginator.count|default:0 }}

+

{{ stats.active|default:0 }}

@@ -159,12 +171,12 @@
- +
{% trans "Hospitals" %}
-

{{ page_obj.paginator.count|default:0 }}

+

{{ stats.hospitals|default:0 }}

@@ -177,26 +189,24 @@
{% trans "Encounters" %}
-

{{ page_obj.paginator.count|default:0 }}

+

{{ stats.visits|default:0 }}

-
-
+
+ placeholder="{% trans 'Name, MRN, SSN, Phone...' %}">
-
- -
- + + +
-
{% trans "Patient List" %}
+ {{ page_obj.paginator.count }}
- - - - - - + + + + + + + + {% for patient in patients %} - - - + - + - - {% empty %} -
{% trans "Patient" %}{% trans "MRN" %}{% trans "Contact" %}{% trans "Hospital" %}{% trans "Status" %}{% trans "Actions" %}{% trans "Patient" %}{% trans "MRN" %}{% trans "National ID" %}{% trans "Contact" %}{% trans "Nationality" %}{% trans "Hospital" %}{% trans "Status" %}{% trans "Actions" %}
+ - + {{ patient.mrn }} + + {{ patient.national_id|default:"-" }} + {% if patient.phone %}
- + {{ patient.phone }}
{% endif %} {% if patient.email %}
- - {{ patient.email }} + + {{ patient.email }}
{% endif %}
+ + {{ patient.nationality|default:"-" }} +
- + {{ patient.primary_hospital.name|default:"-" }}
+ {{ patient.get_status_display }} +
+ title="{% trans 'View' %}" onclick="event.stopPropagation()"> {% if request.user.is_px_admin or request.user.is_hospital_admin %} + title="{% trans 'Edit' %}" onclick="event.stopPropagation()"> {% endif %} @@ -299,7 +336,7 @@
+
@@ -320,14 +357,12 @@
- {% if page_obj.has_other_pages %}
- {% trans "Showing" %} {{ page_obj.start_index }}-{{ page_obj.end_index }} {% trans "of" %} {{ page_obj.paginator.count }} {% trans "entries" %} + {% trans "Showing" %} {{ page_obj.start_index }}-{{ page_obj.end_index }} {% trans "of" %} {{ page_obj.paginator.count }} -
{% for key, value in request.GET.items %} {% if key != 'page_size' and key != 'page' %} @@ -388,10 +423,309 @@ {% endif %}
+ + + + + + + {% endblock %} {% block extra_js %} {% endblock %} diff --git a/templates/organizations/patient_visit_journey.html b/templates/organizations/patient_visit_journey.html new file mode 100644 index 0000000..341db52 --- /dev/null +++ b/templates/organizations/patient_visit_journey.html @@ -0,0 +1,382 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} + +{% block title %}{% trans "Visit Journey" %} - {{ visit.admission_id }} - {{ patient.get_full_name }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + +
+
+ +
+
+

+
+ +
+ {% trans "Visit Journey" %} +

+
+ {{ visit.patient_type }} + {% if visit.is_vip %}VIP{% endif %} + {% if visit.is_visit_complete %} + {% trans "Complete" %} + {% elif visit.survey_instance %} + {% trans "Survey Sent" %} + {% else %} + {% trans "Active" %} + {% endif %} +
+
+ +
+ + {{ visit.admission_id }} + {% if visit.reg_code %} + | + Reg: {{ visit.reg_code }} + {% endif %} +
+ + +
+
+

{% trans "Admission" %}

+

{{ visit.admit_date|date:"d M Y, H:i"|default:"-" }}

+
+
+

{% trans "Discharge" %}

+ {% if visit.discharge_date %} +

{{ visit.discharge_date|date:"d M Y, H:i" }}

+ {% else %} +

{% trans "In Progress" %}

+ {% endif %} +
+
+

{% trans "Hospital" %}

+

{{ visit.hospital.name|default:"-" }}

+
+
+

{% trans "Insurance" %}

+

{{ visit.insurance_company_name|default:"-" }}

+ {% if visit.bill_type %} + {{ visit.bill_type }} + {% endif %} +
+
+
+ + +
+

+
+ +
+ {% trans "Visit Timeline" %} + {{ timeline|length }} {% trans "events" %} +

+ + {% if timeline %} +
+ {% for event in timeline %} +
+
+ +
+
+
+
+ {% if event.patient_type %} + {{ event.patient_type }} + {% endif %} + {% if event.visit_category %} + {{ event.visit_category }} + {% endif %} + {% if event.type %} + {{ event.type }} + {% endif %} +
+ {% if event.parsed_date or event.bill_date %} + + + {% if event.parsed_date %} + {{ event.parsed_date|date:"d M Y, H:i" }} + {% else %} + {{ event.bill_date }} + {% endif %} + + {% endif %} +
+ {% if event.reg_code %} +
+ Reg: {{ event.reg_code }} + {% if event.admission_id %} + | + Adm: {{ event.admission_id }} + {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+
+ +
+

{% trans "No timeline events recorded for this visit" %}

+
+ {% endif %} +
+
+ + +
+ +
+

+
+ +
+ {% trans "Patient" %} +

+
+
+ {{ patient.first_name|first }}{{ patient.last_name|first }} +
+
+ {{ patient.get_full_name }} + {{ patient.mrn }} +
+
+
+ {% if patient.phone %} +
+ + {{ patient.phone }} +
+ {% endif %} + {% if patient.nationality %} +
+ + {{ patient.nationality }} +
+ {% endif %} + {% if patient.national_id %} +
+ + {{ patient.national_id }} +
+ {% endif %} +
+
+ + +
+

+
+ +
+ {% trans "Medical Team" %} +

+
+ {% if visit.primary_doctor_fk %} + + {% else %} +
+

{% trans "Primary Doctor" %}

+

{{ visit.doctor_display }}

+
+ {% endif %} + + {% if visit.consultant_fk %} + + {% elif visit.consultant_id %} +
+

{% trans "Consultant" %}

+

{{ visit.consultant_display }}

+
+ {% endif %} +
+
+ + +
+

+
+ +
+ {% trans "Visit Details" %} +

+
+ {% if visit.company_name %} +
+

{% trans "Company" %}

+

{{ visit.company_name }}

+
+ {% endif %} + {% if visit.grade_name %} +
+

{% trans "Grade" %}

+

{{ visit.grade_name }}

+
+ {% endif %} + {% if visit.nationality %} +
+

{% trans "Nationality" %}

+

{{ visit.nationality }}

+
+ {% endif %} +
+

{% trans "Patient ID (HIS)" %}

+

{{ visit.patient_id_his|default:"-" }}

+
+ {% if visit.last_his_fetch_at %} +
+

{% trans "Last HIS Sync" %}

+

{{ visit.last_his_fetch_at|date:"d M Y, H:i" }}

+
+ {% endif %} +
+
+ + + {% if visit.survey_instance %} +
+
+
+
+ +

{% trans "Survey" %}

+
+

{{ visit.survey_instance.survey_template.name }}

+
+ {% if visit.survey_instance.sent_at %} + + + {{ visit.survey_instance.sent_at|date:"d M Y H:i" }} + + {% endif %} + {% if visit.survey_instance.total_score %} + {{ visit.survey_instance.total_score }} + {% endif %} +
+
+
+ {% endif %} +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/physicians/individual_ratings_list.html b/templates/physicians/individual_ratings_list.html index 60d8fb6..eb86fa2 100644 --- a/templates/physicians/individual_ratings_list.html +++ b/templates/physicians/individual_ratings_list.html @@ -182,7 +182,7 @@ {% trans "Date" %} {% trans "Doctor" %} - {% trans "Patient" %} + {% trans "Patient (if available)" %} {% trans "Rating" %} {% trans "Department" %} {% trans "Source" %} @@ -216,8 +216,18 @@
-
{{ rating.patient_name|truncatechars:20 }}
-
{{ rating.patient_uhid }}
+ {% if rating.patient_name %} +
{{ rating.patient_name|truncatechars:20 }}
+
{{ rating.patient_uhid|default:"" }}
+ {% else %} +
+ {% if rating.source == 'his_api' %} + {% trans "HIS Rating" %} + {% else %} + {% trans "N/A" %} + {% endif %} +
+ {% endif %}
diff --git a/templates/references/folder_view.html b/templates/references/folder_view.html index b2e24ba..80ec8bd 100644 --- a/templates/references/folder_view.html +++ b/templates/references/folder_view.html @@ -297,140 +297,4 @@
-{% endblock %} - - {% trans "Search" %} - - {% if current_folder %} - - {% trans "New Folder" %} - - {% else %} - - {% trans "New Folder" %} - - {% endif %} - {% if current_folder %} - - {% trans "Upload Document" %} - - {% else %} - - {% trans "Upload Document" %} - - {% endif %} - - - - - {% if subfolders %} - - {% endif %} - - -
-
-
- -
-
{% trans "Documents" %}
-
-
- {% if documents %} -
- - - - - - - - - - - - - - {% for document in documents %} - - - - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "Type" %}{% trans "Size" %}{% trans "Version" %}{% trans "Downloads" %}{% trans "Modified" %}
- - - {{ document.title }} - - - {{ document.file_type|upper }} - {{ document.get_file_size_display }} - {% if document.is_latest_version %} - {{ document.version }} - {% else %} - {{ document.version }} - {% endif %} - {{ document.download_count }}{{ document.updated_at|date:"M d, Y" }} -
- - - -
-
-
- {% else %} -
- -

{% trans "No documents in this folder" %}

- {% if current_folder %} - - {% trans "Upload Document" %} - - {% else %} - - {% trans "Upload Document" %} - - {% endif %} -
- {% endif %} -
-
- {% endblock %} diff --git a/templates/standards/department_standards.html b/templates/standards/department_standards.html index 6cacf32..4760e22 100644 --- a/templates/standards/department_standards.html +++ b/templates/standards/department_standards.html @@ -173,17 +173,26 @@ window.CSRF_TOKEN = '{{ csrf_token }}'; - {% if item.compliance %} - - {% else %} - - {% endif %} +
+ {% if item.compliance %} + + + {% else %} + + {% endif %} +
{% empty %} @@ -270,6 +279,114 @@ window.CSRF_TOKEN = '{{ csrf_token }}'; + + + {% endblock %} diff --git a/templates/surveys/instance_detail.html b/templates/surveys/instance_detail.html index 8787596..028e69c 100644 --- a/templates/surveys/instance_detail.html +++ b/templates/surveys/instance_detail.html @@ -204,7 +204,7 @@ {% if survey.status == 'completed' %} -
+

{% trans "Patient Comment" %} @@ -339,6 +339,35 @@
+ +
+

+ + {% trans "Survey Link" %} +

+
+ {{ survey_url }} +
+ + {% if survey.token_expires_at %} +
+ + {% trans "Expires" %}: {{ survey.token_expires_at|date:"M d, Y H:i" }} +
+ {% endif %} +
+ + {% if survey.status == 'completed' and survey.comment %} + + + {% trans "Survey Comments" %} + + + {% endif %} +

@@ -421,6 +450,30 @@

+ + {% if visit_timeline %} +
+

+ + {% trans "Visit Timeline" %} +

+
+ {% for visit in visit_timeline %} +
+
+
{{ visit.bill_date }}
+
{{ visit.type }}
+
+ + {{ visit.patient_type }} + +
+
+ {% endfor %} +
+
+ {% endif %} + {% if survey.journey_instance %}
diff --git a/templates/surveys/template_confirm_delete.html b/templates/surveys/template_confirm_delete.html new file mode 100644 index 0000000..54ceaa7 --- /dev/null +++ b/templates/surveys/template_confirm_delete.html @@ -0,0 +1,138 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Delete Survey Template" %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+
+
+

+ + {% trans "Delete Survey Template" %} +

+

{% trans "Confirm deletion of survey template" %}

+
+ + + {% trans "Back to Templates" %} + +
+
+ + +
+
+
+ +
+
+

{% trans "Warning: This action cannot be undone" %}

+

{% trans "All associated data will be permanently deleted" %}

+
+
+ +
+

{% trans "The following will be deleted:" %}

+
    +
  • + + {% trans "Survey template" %}: {{ template.name }} +
  • +
  • + + {% trans "All questions associated with this template" %} +
  • +
  • + + {% trans "All survey instances and responses linked to this template" %} +
  • +
+
+ +
+ {% csrf_token %} +
+ + + {% trans "Cancel" %} + + +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/surveys/template_detail.html b/templates/surveys/template_detail.html index e676fda..688e0d1 100644 --- a/templates/surveys/template_detail.html +++ b/templates/surveys/template_detail.html @@ -1,256 +1,290 @@ {% extends "layouts/base.html" %} {% load i18n %} -{% block title %}{{ template.name }} - PX360{% endblock %} +{% block title %}{{ template.name }} - {% trans "Survey Template" %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} {% block content %} - -
-
-

- - {{ template.name }} -

-

{{ template.name_ar }}

-
-
- - {% trans "Back" %} - - - {% trans "Edit" %} - - -
+ + - -
-
-
-
- -
-
-

{% trans "Total Instances" %}

-

{{ stats.total_instances }}

-
-
-
-
-
-
- -
-
-

{% trans "Completed" %}

-

{{ stats.completed_instances }}

-
-
-
-
-
-
- -
-
-

{% trans "Negative" %}

-

{{ stats.negative_instances }}

-
-
-
-
-
-
- -
-
-

{% trans "Avg Score" %}

-

{{ stats.avg_score }}

-
-
-
-
- - -
- -
-
-

{% trans "Template Details" %}

-
-
-
-
- {% trans "Hospital" %} - {{ template.hospital.name_en }} -
-
- {% trans "Survey Type" %} - {{ template.get_survey_type_display }} -
-
- {% trans "Scoring Method" %} - {{ template.get_scoring_method_display }} -
-
- {% trans "Negative Threshold" %} - {{ template.negative_threshold }} -
-
- {% trans "Status" %} - {% if template.is_active %} - {% trans "Active" %} - {% else %} - {% trans "Inactive" %} - {% endif %} -
-
- {% trans "Created By" %} - {{ template.created_by.get_full_name|default:"System" }} -
-
- {% trans "Created At" %} - {{ template.created_at|date:"Y-m-d H:i" }} -
-
-
-
- - -
-
-

{% trans "Statistics" %}

-
-
-
-
- {% trans "Total Instances" %} - {{ stats.total_instances }} -
-
- {% trans "Completed" %} - {{ stats.completed_instances }} -
-
- {% trans "Completion Rate" %} - {{ stats.completion_rate }}% -
-
- {% trans "Negative Responses" %} - {{ stats.negative_instances }} -
-
- {% trans "Average Score" %} - {{ stats.avg_score }} -
-
-
-
-
- - -
-
-

{% trans "Questions" %} ({{ questions.count }})

-
-
- {% if questions %} -
- - - - - - - - - - - - {% for question in questions %} - - - - - - - - {% endfor %} - -
{% trans "Order" %}{% trans "Question (EN)" %}{% trans "Question (AR)" %}{% trans "Type" %}{% trans "Required" %}
- - {{ question.order }} - - {{ question.text }}{{ question.text_ar|default:"-" }} - {{ question.get_question_type_display }} - - {% if question.is_required %} - {% trans "Yes" %} - {% else %} - {% trans "No" %} - {% endif %} -
-
+ +
+
+ {% trans "Templates" %} + + {{ template.name }} + {% if template.is_active %} + {% trans "Active" %} {% else %} -
-
- -
-

{% trans "No Questions" %}

-

{% trans "No questions have been added to this template yet." %}

-
+ {% trans "Inactive" %} {% endif %}
-
- - -

- {% if template %}{% trans "Modify survey template and questions" %}{% else %}{% trans "Create a new survey template with questions" %}{% endif %} + {% if template %}{% trans "Modify this survey template and its questions" %}{% else %}{% trans "Create a new survey template with questions" %}{% endif %}

@@ -227,24 +120,25 @@ - +
-
+ {% csrf_token %} - - + {{ formset.management_form }} + +
-

{% trans "Template Details" %}

+

{% trans "General Settings" %}

- +
{{ form.name }} {% if form.name.errors %} @@ -253,7 +147,7 @@
{{ form.name_ar }} {% if form.name_ar.errors %} @@ -262,21 +156,21 @@
-
- - {% if not form.hospital.is_hidden %} +
+ {% if not form.hospital.is_hidden %} {{ form.hospital }} + {% else %} + {{ form.hospital }} + {% endif %} {% if form.hospital.errors %}
{{ form.hospital.errors }}
{% endif %} +

{% trans "Leave blank to apply to all hospitals" %}

- {% else %} - {{ form.hospital }} - {% endif %}
-
- -
+
+ +
-
- -
- - {% if form.is_active.errors %} -
{{ form.is_active.errors }}
- {% endif %} -
+
+ + {% if form.is_active.errors %} +
{{ form.is_active.errors }}
+ {% endif %}

- -
+ +
-
- +
+

{% trans "Questions" %}

-
- +
- {{ formset.management_form }} - - -