Compare commits

..

22 Commits
faheed ... main

Author SHA1 Message Date
Marwan Alwali
7bddee1647 update 2026-02-02 16:50:42 +03:00
3c44f28d33 survey charts and analytics 2026-01-25 10:39:37 +03:00
42cf7bf8f1 update on the surevey 2026-01-24 15:27:30 +03:00
3ce62d80e1 small changes 2026-01-24 15:27:27 +03:00
9d586a4ed3 update on the complaint sla and staff hierarchy 2026-01-24 15:26:30 +03:00
65490078bb update the complaint and inquiry creation for the source user 2026-01-24 15:25:53 +03:00
d0a2d5db7b standards app done 2026-01-24 15:24:58 +03:00
aac8698df4 update on the complaint sla and staff hierarchy 2026-01-24 15:24:39 +03:00
dcb6455819 update the complaint and inquiry creation for the source user 2026-01-24 15:13:14 +03:00
4dd3c3e505 standards app done 2026-01-24 15:07:27 +03:00
Marwan Alwali
7d6d75b10b update-admin-stuff 2026-01-21 14:27:29 +03:00
Marwan Alwali
9d694c7ab3 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps/accounts/admin.py
2026-01-18 14:05:58 +03:00
Marwan Alwali
524efbead9 update-po-file 2026-01-18 14:04:23 +03:00
6f2c783577 update on the complaint and fix merge conflict 2026-01-15 15:25:09 +03:00
e3b3490bc9 update on the complaint sla and staff hierarchy 2026-01-15 15:22:57 +03:00
8b65f9a52e update the complaint and inquiry creation for the source user 2026-01-15 15:07:32 +03:00
90dd2a66af standards app done 2026-01-15 15:02:42 +03:00
1f9d8a7198 update on the complaint sla and staff hierarchy 2026-01-15 14:32:49 +03:00
5185849c6d requirements.txt updated 2026-01-15 14:31:58 +03:00
ec675dbc4e social and source app 2026-01-15 14:31:58 +03:00
Marwan Alwali
02984811ab update 2026-01-13 17:01:46 +03:00
Marwan Alwali
d7847da450 update 2026-01-12 13:20:34 +03:00
272 changed files with 61416 additions and 6100 deletions

View File

@ -34,6 +34,37 @@ WHATSAPP_PROVIDER=console
EMAIL_ENABLED=True
EMAIL_PROVIDER=console
# External API Notification Configuration
# Email API
EMAIL_API_ENABLED=False
EMAIL_API_URL=https://api.yourservice.com/send-email
EMAIL_API_KEY=your-api-key-here
EMAIL_API_AUTH_METHOD=bearer
EMAIL_API_METHOD=POST
EMAIL_API_TIMEOUT=10
EMAIL_API_MAX_RETRIES=3
EMAIL_API_RETRY_DELAY=2
# SMS API
SMS_API_ENABLED=False
SMS_API_URL=https://api.yourservice.com/send-sms
SMS_API_KEY=your-api-key-here
SMS_API_AUTH_METHOD=bearer
SMS_API_METHOD=POST
SMS_API_TIMEOUT=10
SMS_API_MAX_RETRIES=3
SMS_API_RETRY_DELAY=2
# 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
# EMAIL_API_URL=http://localhost:8000/api/simulator/send-email
# EMAIL_API_KEY=simulator-test-key
# SMS_API_ENABLED=True
# SMS_API_URL=http://localhost:8000/api/simulator/send-sms
# SMS_API_KEY=simulator-test-key
# Admin URL (change in production)
ADMIN_URL=admin/

4
.gitignore vendored
View File

@ -70,3 +70,7 @@ Thumbs.db
# Docker volumes
postgres_data/
# Django migrations (exclude __init__.py)
**/migrations/*.py
!**/migrations/__init__.py

View File

@ -0,0 +1,217 @@
# Post-Discharge Survey Implementation
## Overview
This implementation replaces the per-stage survey system with a comprehensive post-discharge survey that merges questions from all completed stages into a single survey sent after patient discharge.
## Changes Made
### 1. Model Changes
#### PatientJourneyTemplate Model
- **Added:**
- `send_post_discharge_survey`: Boolean field to enable/disable post-discharge surveys
- `post_discharge_survey_delay_hours`: Integer field for delay after discharge (in hours)
#### PatientJourneyStageTemplate Model
- **Removed:**
- `auto_send_survey`: No longer auto-send surveys at each stage
- `survey_delay_hours`: No longer needed for individual stage surveys
- **Retained:**
- `survey_template`: Still linked for collecting questions to merge
### 2. Task Changes
#### process_inbound_event (apps/integrations/tasks.py)
- **New Logic:**
- Detects `patient_discharged` event code
- Checks if journey template has `send_post_discharge_survey=True`
- Schedules `create_post_discharge_survey` task with configured delay
- **Removed:**
- No longer triggers surveys at individual stage completion
#### create_post_discharge_survey (apps/surveys/tasks.py)
- **New Task:**
- Fetches all completed stages for the journey
- Collects survey templates from each completed stage
- Creates a comprehensive survey template on-the-fly
- Merges questions from all stages with section headers
- Sends the comprehensive survey to the patient
### 3. Admin Changes
#### PatientJourneyStageTemplateInline
- **Removed:**
- `auto_send_survey` from inline fields
- **Retained:**
- `survey_template` for question configuration
#### PatientJourneyStageTemplateAdmin
- **Removed:**
- `auto_send_survey` from list_display, list_filter, fieldsets
- `survey_delay_hours` from fieldsets
#### PatientJourneyTemplateAdmin
- **Added:**
- New "Post-Discharge Survey" fieldset with:
- `send_post_discharge_survey`
- `post_discharge_survey_delay_hours`
## How It Works
### Workflow
1. **Patient Journey Starts:**
- Patient goes through various stages (admission, treatment, etc.)
- Each stage has a `survey_template` configured with questions
- No surveys are sent at this point
2. **Patient Discharges:**
- System receives `patient_discharged` event via `process_inbound_event`
- If `send_post_discharge_survey=True` on journey template:
- Schedules `create_post_discharge_survey` task after configured delay
3. **Comprehensive Survey Created:**
- Task collects all completed stages
- Creates new survey template with merged questions
- Questions organized with section headers for each stage
- Survey sent to patient via SMS/WhatsApp/Email
4. **Patient Responds:**
- Patient completes the comprehensive survey
- System calculates score and processes feedback
- Negative scores trigger PX Actions (existing functionality)
## Survey Structure
The post-discharge survey includes:
```
Post-Discharge Survey - [Patient Name] - [Encounter ID]
--- Stage 1 Name ---
[Question 1 from Stage 1]
[Question 2 from Stage 1]
...
--- Stage 2 Name ---
[Question 1 from Stage 2]
[Question 2 from Stage 2]
...
--- Stage 3 Name ---
[Question 1 from Stage 3]
[Question 2 from Stage 3]
...
```
## Configuration
### Enabling Post-Discharge Surveys
1. Go to Admin → Patient Journey Templates
2. Select or create a journey template
3. In "Post-Discharge Survey" section:
- Check "Send post-discharge survey"
- Set "Post-discharge survey delay (hours)" (default: 24)
### Setting Stage Questions
1. Go to Patient Journey Templates → Edit Template
2. For each stage in "Journey stage templates" section:
- Select a `Survey template` (contains questions)
- These questions will be merged into the post-discharge survey
## Benefits
1. **Reduced Survey Fatigue:** One comprehensive survey instead of multiple surveys
2. **Better Patient Experience:** Patients not overwhelmed with frequent surveys
3. **Complete Picture:** Captures feedback for entire hospital stay
4. **Flexible Configuration:** Easy to enable/disable per journey template
5. **Contextual Organization:** Questions grouped by stage for clarity
## Migration Details
**Migration File:** `apps/journeys/migrations/0003_remove_patientjourneystagetemplate_auto_send_survey_and_more.py`
**Changes:**
- Remove `auto_send_survey` from `PatientJourneyStageTemplate`
- Remove `survey_delay_hours` from `PatientJourneyStageTemplate`
- Add `send_post_discharge_survey` to `PatientJourneyTemplate`
- Add `post_discharge_survey_delay_hours` to `PatientJourneyTemplate`
- Make `survey_template` nullable on `PatientJourneyStageTemplate`
## Task Parameters
### create_post_discharge_survey
**Parameters:**
- `journey_instance_id`: UUID of the PatientJourneyInstance
**Returns:**
```python
{
'status': 'sent' | 'skipped' | 'error',
'survey_instance_id': str,
'survey_template_id': str,
'notification_log_id': str,
'stages_included': int,
'total_questions': int,
'reason': str # if skipped/error
}
```
**Skip Conditions:**
- No completed stages in journey
- No survey templates found for completed stages
## Audit Events
The implementation creates audit logs for:
- `post_discharge_survey_sent`: When comprehensive survey is created and sent
**Metadata includes:**
- `survey_template`: Name of comprehensive survey
- `journey_instance`: Journey instance ID
- `encounter_id`: Patient encounter ID
- `stages_included`: Number of stages merged
- `total_questions`: Total questions in survey
- `channel`: Delivery channel (sms/whatsapp/email)
## Future Enhancements
Potential improvements:
1. Add per-stage question filtering (optional stages)
2. Allow custom question ordering
3. Add conditional questions based on stage outcomes
4. Implement survey reminders for post-discharge surveys
5. Add analytics comparing pre/post implementation metrics
## Testing Checklist
- [ ] Verify journey template has post-discharge survey enabled
- [ ] Create journey with multiple stages, each with survey templates
- [ ] Complete all stages
- [ ] Send `patient_discharged` event
- [ ] Verify task is scheduled with correct delay
- [ ] Verify comprehensive survey is created
- [ ] Verify all stage questions are merged with section headers
- [ ] Verify survey is sent to patient
- [ ] Test patient survey completion
- [ ] Verify score calculation works correctly
- [ ] Verify negative survey triggers PX Action
## Rollback Plan
If needed, rollback steps:
1. Disable `send_post_discharge_survey` on all journey templates
2. Revert migration: `python manage.py migrate journeys 0002`
3. Manually restore `auto_send_survey` and `survey_delay_hours` fields if needed
4. Update `process_inbound_event` to restore stage survey logic
## Related Documentation
- [Journey Engine](docs/JOURNEY_ENGINE.md)
- [Survey System](docs/IMPLEMENTATION_STATUS.md#survey-system)
- [Notifications](docs/IMPLEMENTATION_STATUS.md#notification-system)
- [PX Action Center](docs/IMPLEMENTATION_STATUS.md#px-action-center)

View File

@ -0,0 +1,153 @@
# Staff Hierarchy Page Fix
## Problem Identified
The staff hierarchy page was not displaying properly because the organization has **17 separate hierarchy trees** (17 top-level managers) instead of a single unified hierarchy.
D3.js tree visualizations require a **single root node** to render correctly. When the API returned multiple disconnected root nodes, the visualization failed to display any content.
### Data Statistics
- **Total Staff**: 1,968
- **Top-Level Managers (Root Nodes)**: 17
- **Issue**: 17 disconnected trees cannot be rendered by D3.js without a virtual root
## Solution Implemented
### 1. API Fix (`apps/organizations/views.py`)
Modified the `hierarchy` action in `StaffViewSet` to:
1. **Detect multiple root nodes**: Identify when there are multiple top-level managers
2. **Create virtual root**: When multiple roots exist, create a virtual "Organization" node
3. **Wrap hierarchies**: Place all root nodes as children under the virtual root
4. **Return single tree**: API always returns a single tree structure that D3.js can render
**Key Changes:**
```python
# If there are multiple root nodes, wrap them in a virtual "Organization" node
if len(root_nodes) > 1:
hierarchy = [{
'id': None, # Virtual root has no real ID
'name': 'Organization',
'is_virtual_root': True # Flag to identify this is a virtual node
'children': root_nodes
}]
```
### 2. Template Fix (`templates/organizations/staff_hierarchy_d3.html`)
Updated the D3.js visualization to:
1. **Handle virtual root**: Recognize and style the virtual root node differently
2. **Prevent navigation**: Disable double-click navigation to virtual root (no staff detail page)
3. **Visual distinction**: Make virtual root larger and use different colors
**Key Changes:**
- Virtual root node radius: 20px (vs 10px for regular nodes)
- Virtual root color: Gray (#666) to distinguish from real staff
- Cursor style: Default (not clickable) for virtual root
- Navigation check: Prevent double-click navigation to `/organizations/staff/None/`
## Files Modified
1. **`apps/organizations/views.py`**
- Modified `StaffViewSet.hierarchy()` action
- Added virtual root node logic for multiple hierarchies
2. **`templates/organizations/staff_hierarchy_d3.html`**
- Updated node styling for virtual root
- Modified double-click handler to prevent navigation to virtual root
- Enhanced node update transitions
## Testing the Fix
### Verify the Fix Works
1. **Start the server** (if not running):
```bash
python manage.py runserver
```
2. **Login to the application** with your credentials
3. **Navigate to the hierarchy page**:
- Go to Organizations > Staff > Hierarchy
- Or visit: `http://localhost:8000/organizations/staff/hierarchy/`
4. **Expected behavior**:
- You should see a single organizational chart
- Top-level "Organization" node (virtual root, gray color, larger)
- 17 top-level managers as children of the virtual root
- All 1,968 staff members displayed in the hierarchy
- Click on nodes to expand/collapse
- Double-click on staff nodes (not virtual root) to view details
### Check the API Response
If you want to verify the API is returning the correct structure:
```python
python manage.py shell << 'EOF'
from django.test import RequestFactory
from apps.organizations.views import StaffViewSet
from apps.accounts.models import User
# Create a mock request
factory = RequestFactory()
request = factory.get('/organizations/api/staff/hierarchy/')
# Create a mock user (PX Admin)
request.user = User.objects.filter(is_px_admin=True).first()
# Call the viewset action
viewset = StaffViewSet()
viewset.request = request
viewset.format_kwarg = None
response = viewset.hierarchy(request)
# Check response
import json
data = json.loads(response.content)
print(f"Total staff: {data['statistics']['total_staff']}")
print(f"Top managers: {data['statistics']['top_managers']}")
print(f"Virtual root created: {data['hierarchy'][0].get('is_virtual_root', False)}")
print(f"Children of virtual root: {len(data['hierarchy'][0].get('children', []))}")
EOF
```
## Benefits of This Fix
1. **Single Unified View**: All staff hierarchies are now visible in one cohesive visualization
2. **No Data Loss**: All 1,968 staff members are displayed
3. **Better UX**: Users can see the entire organizational structure at a glance
4. **Flexible**: Works with any number of hierarchies (1, 17, or more)
5. **Backward Compatible**: Single hierarchies still work without virtual root
## Virtual Root Node Details
The virtual root node has these characteristics:
- **Name**: "Organization"
- **ID**: `None` (no real database ID)
- **is_virtual_root**: `true` (flag for identification)
- **color**: Gray (#666) to distinguish from real staff
- **size**: 20px radius (larger than regular 10px nodes)
- **cursor**: Default (not clickable)
- **navigation**: Disabled (double-click does nothing)
## Future Enhancements
Potential improvements for the hierarchy visualization:
1. **Hospital Filtering**: Add dropdown to filter by hospital
2. **Department Filtering**: Add dropdown to filter by department
3. **Export Options**: Add ability to export hierarchy as PDF or image
4. **Search Enhancement**: Highlight search results in the tree
5. **Organization Grouping**: Group hierarchies by hospital under virtual root
6. **Collapsible Virtual Root**: Allow hiding the virtual root label
## Related Documentation
- `docs/STAFF_HIERARCHY_INTEGRATION_SUMMARY.md` - Original integration documentation
- `docs/D3_HIERARCHY_INTEGRATION.md` - D3.js implementation details
- `docs/STAFF_HIERARCHY_IMPORT_GUIDE.md` - Staff data import guide

177
SURVEY_CHARTS_EMPTY_FIX.md Normal file
View File

@ -0,0 +1,177 @@
# Survey Charts Empty - Fix Summary
## Issue
Charts on the survey responses list page were displaying empty, even though data existed in the database.
## Root Cause Analysis
### 1. **Hospital Mismatch in RBAC**
The primary issue was a mismatch between survey hospital assignments and user hospital assignments:
- **Surveys were in**: "Al Hammadi Hospital" (Code: `ALH-main`) - 57 surveys
- **Users were assigned to**: "Alhammadi Hospital" (Code: `HH`)
- **Result**: RBAC filters excluded all surveys for non-PX Admin users
### 2. **RBAC Logic in View**
The survey list view applies strict hospital-based filtering:
```python
# From apps/surveys/ui_views.py
if request.user.is_px_admin():
stats_queryset = SurveyInstance.objects.all()
elif request.user.is_hospital_admin() and request.user.hospital:
stats_queryset = SurveyInstance.objects.filter(
survey_template__hospital=request.user.hospital
)
elif request.user.hospital:
stats_queryset = SurveyInstance.objects.filter(
survey_template__hospital=request.user.hospital
)
else:
stats_queryset = SurveyInstance.objects.none()
```
When users didn't have matching hospital assignments, `stats_queryset` became empty, resulting in all charts showing no data.
## User Access Status After Fix
| User | PX Admin | Hospital | Visible Surveys |
|------|----------|----------|-----------------|
| test_admin | ❌ | None | 0 (no permissions/hospital) |
| test.user | ❌ | Alhammadi Hospital | **57** ✓ |
| mohamad.a al gailani | ❌ | Alhammadi Hospital | **57** ✓ |
| admin_hh | ✓ | Alhammadi Hospital | **57** ✓ |
| px_admin | ✓ | None | **57** ✓ |
| ismail@tenhal.sa | ❌ | None | 0 (no PX Admin role) |
## Fix Applied
### Moved Survey Templates and Instances
```bash
# Updated 4 survey templates from ALH-main to HH
# Result: 57 surveys now visible to users assigned to HH hospital
```
### Hospital Assignment Summary
```
Before Fix:
- Al Hammadi Hospital (ALH-main): 57 surveys
- Alhammadi Hospital (HH): 0 surveys
After Fix:
- Al Hammadi Hospital (ALH-main): 0 surveys
- Alhammadi Hospital (HH): 57 surveys
```
## Technical Details
### Chart Data Verified
All 5 charts have valid data:
1. **Score Distribution**: 29 completed surveys with scores
- 1-2: 4 surveys
- 2-3: 7 surveys
- 3-4: 10 surveys
- 4-5: 8 surveys
2. **Engagement Funnel**:
- Sent/Pending: 18
- Viewed: 2
- Opened: 7
- In Progress: 6
- Completed: 29
3. **Completion Time**: 29 surveys with time data
- < 1 min: 6 surveys
- 1-5 min: 6 surveys
- 5-10 min: 6 surveys
- 10-20 min: 5 surveys
- 20+ min: 6 surveys
4. **Device Types**: 29 tracking events
- desktop: 22 events
- mobile: 7 events
5. **30-Day Trend**: 23 days of activity with sent and completed data
### View and Template Confirmed Working
- ✓ View code correctly generates chart data
- ✓ Template correctly renders chart containers
- ✓ ApexCharts library loaded and functional
- ✓ Chart configuration properly formatted
## Instructions for Users
### For users who can now see charts:
- Login as `test.user`, `mohamad.a al gailani`, `admin_hh`, or `px_admin`
- Navigate to the survey responses list page
- Charts will now display data with 57 surveys
### For users who still cannot see charts:
**User: test_admin**
- Superuser but not PX Admin
- No hospital assigned
- **Fix**: Assign PX Admin role or assign to a hospital
**User: ismail@tenhal.sa**
- Superuser but not PX Admin
- No hospital assigned
- **Fix**: Assign PX Admin role or assign to a hospital
To fix these users, run:
```python
from apps.accounts.models import User
from django.contrib.auth.models import Group
# Option 1: Assign PX Admin role
user = User.objects.get(email='ismail@tenhal.sa')
px_admin_group = Group.objects.get(name='PX Admin')
user.groups.add(px_admin_group)
# Option 2: Assign to hospital (requires hospital to have surveys)
user = User.objects.get(email='ismail@tenhal.sa')
from apps.organizations.models import Hospital
hospital = Hospital.objects.get(code='HH')
user.hospital = hospital
user.save()
```
## Prevention
To prevent this issue in the future:
1. **Consistent Hospital Codes**: Ensure surveys are always created for the correct hospital
2. **User Setup**: Verify user hospital assignments match survey hospitals
3. **PX Admin Role**: Use PX Admin role for users who need to see all surveys
4. **Testing**: Test chart display after creating new surveys or adding users
## Files Modified/Checked
- ✅ `apps/surveys/ui_views.py` - View logic (already correct)
- ✅ `templates/surveys/survey_responses_list.html` - Template (already correct)
- ✅ `apps/surveys/models.py` - Models (working correctly)
- ✅ `apps/accounts/models.py` - User model (working correctly)
## Diagnostic Scripts Created
1. `diagnose_charts.py` - Tests chart data generation
2. `check_user_permissions.py` - Checks user permissions and hospital assignments
3. `fix_survey_hospital.py` - Fixes hospital assignment mismatches
## Verification Steps
1. Login as a user with proper permissions (e.g., test.user)
2. Navigate to survey responses list page
3. Verify all 5 charts display data
4. Check that score distribution shows 4 bars with counts
5. Check that engagement funnel shows 5 stages with counts
6. Check that completion time shows 5 time ranges
7. Check that device types show mobile/desktop breakdown
8. Check that trend chart shows 30-day activity
## Conclusion
The empty charts issue was caused by hospital RBAC filtering excluding surveys due to hospital code mismatches. By reassigning surveys to the correct hospital (HH), users with matching hospital assignments can now see their survey data in all charts.
The fix is complete and working for users `test.user`, `mohamad.a al gailani`, `admin_hh`, and `px_admin`.

View File

@ -0,0 +1,76 @@
# Survey Charts Fix Summary
## Issue
The survey response list page had empty charts showing no data, even though survey data existed in the database.
## Root Cause
The **Score Distribution** chart had a range query bug: the 4-5 range used `__lt=5` (less than 5), which excluded surveys with a score of exactly 5.0.
## Fixes Applied
### 1. Fixed Score Distribution Range Logic
**File:** `apps/surveys/ui_views.py`
**Change:**
```python
# BEFORE (line 294-298):
if max_score == 5:
count = stats_queryset.filter(
total_score__gte=min_score,
total_score__lt=max_score # <-- This excluded score 5.0
).count()
# AFTER:
if max_score == 5:
count = stats_queryset.filter(
total_score__gte=min_score,
total_score__lte=max_score # <-- Now includes score 5.0
).count()
```
### 2. Added Debug Logging
Added comprehensive logging to help troubleshoot chart data issues in the future.
## Verification Results
### Score Distribution ✓
- 1-2: 0 surveys (0.0%)
- 2-3: 1 survey (16.7%) - score: 2.71
- 3-4: 3 surveys (50.0%) - scores: 3.50, 3.71, 3.71
- 4-5: 2 surveys (33.3%) - scores: 4.00, **5.00** (now included!)
### Engagement Funnel ✓
- Sent/Pending: 9 surveys
- Viewed: 0 surveys
- Opened: 4 surveys
- In Progress: 3 surveys
- Completed: 6 surveys
### Completion Time Distribution ✓
- < 1 min: 3 surveys (50.0%)
- 1-5 min: 0 surveys (0.0%)
- 5-10 min: 0 surveys (0.0%)
- 10-20 min: 0 surveys (0.0%)
- 20+ min: 3 surveys (50.0%)
### 30-Day Trend ✓
Already working (confirmed by user)
## What Was Working
- Engagement Funnel (had correct logic)
- Completion Time (had correct logic)
- 30-Day Trend (already working)
## What Was Fixed
- Score Distribution (range query bug fixed)
## Test Instructions
1. Access the survey instances page: `http://localhost:8000/surveys/instances/`
2. Verify all charts are now displaying data
3. Check the Score Distribution chart shows the 4-5 range with 2 surveys
## Technical Notes
- All charts use ApexCharts library (version 3.45.1)
- Chart data is generated server-side in the `survey_instance_list` view
- Template variables correctly map to JavaScript chart configuration
- Debug logging available in Django logs for troubleshooting

61
api_example.txt Normal file
View File

@ -0,0 +1,61 @@
https://his.alhammadi.med.sa/ClinicalsAPiT/API/FetchPatientVisitTimeStamps?AdmissionID=204541
{
"FetchPatientDataTimeStampList": [
{
"Type": "Patient Demographic details",
"PatientID": "878943",
"AdmissionID": "204541",
"HospitalID": "3",
"HospitalName": "NUZHA-UAT",
"PatientType": "1",
"AdmitDate": "05-Jun-2025 11:06",
"DischargeDate": null,
"RegCode": "ALHH.0000343014",
"SSN": "2180292530",
"PatientName": "AFAF NASSER ALRAZoooOOQ",
"GenderID": "1",
"Gender": "Male",
"FullAge": "46 Year(s)",
"PatientNationality": "Saudi",
"MobileNo": "0550137137",
"DOB": "18-Feb-1979 00:00",
"ConsultantID": "409",
"PrimaryDoctor": "6876-Ahmad Hassan Kakaa ",
"CompanyID": "52799",
"GradeID": "2547",
"CompanyName": "Al Hammadi for Mgmt / Arabian Shield",
"GradeName": "A",
"InsuranceCompanyName": "Arabian Shield Cooperative Insurance Company",
"BillType": "CR",
"IsVIP": "0"
}
],
"FetchPatientDataTimeStampVisitDataList": [
{
"Type": "Consultation",
"BillDate": "05-Jun-2025 11:06"
},
{
"Type": "Doctor Visited",
"BillDate": "05-Jun-2025 11:06"
},
{
"Type": "Clinical Condtion",
"BillDate": "05-Jun-2025 11:12"
},
{
"Type": "ChiefComplaint",
"BillDate": "05-Jun-2025 11:12"
},
{
"Type": "Prescribed Drugs",
"BillDate": "05-Jun-2025 11:12"
}
],
"Code": 200,
"Status": "Success",
"Message": "",
"Message2L": "",
"MobileNo": "",
"ValidateMessage": ""
}

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class AppreciationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'appreciation'

View File

@ -1,3 +0,0 @@
from django.db import models
# Create your models here.

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

@ -1,16 +1,34 @@
"""
Accounts admin
"""
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
from django.utils.translation import gettext_lazy as _
from .models import Role, User
class UserChangeForm(BaseUserChangeForm):
"""Custom user change form that handles nullable username field."""
# Override username field to use a regular CharField that handles None
username = forms.CharField(
max_length=150,
required=False,
help_text=_('Optional. 150 characters or fewer.'),
)
class Meta(BaseUserChangeForm.Meta):
model = User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
"""Custom User admin"""
form = UserChangeForm
list_display = ['email', 'username', 'first_name', 'last_name', 'hospital', 'department', 'is_active', 'is_staff']
list_filter = ['is_active', 'is_staff', 'is_superuser', 'groups', 'hospital', 'department']
search_fields = ['email', 'username', 'first_name', 'last_name', 'employee_id']
@ -41,7 +59,7 @@ class UserAdmin(BaseUserAdmin):
if obj:
return self.readonly_fields + ['username']
return self.readonly_fields
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('hospital', 'department')

View File

@ -1,140 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
('username', models.CharField(blank=True, max_length=150, null=True)),
('phone', models.CharField(blank=True, max_length=20)),
('employee_id', models.CharField(blank=True, db_index=True, max_length=50)),
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
('bio', models.TextField(blank=True)),
('language', models.CharField(choices=[('en', 'English'), ('ar', 'Arabic')], default='en', max_length=5)),
('is_active', models.BooleanField(default=True)),
('is_provisional', models.BooleanField(default=False, help_text='User is in onboarding process')),
('invitation_token', models.CharField(blank=True, help_text='Token for account activation', max_length=100, null=True, unique=True)),
('invitation_expires_at', models.DateTimeField(blank=True, help_text='When the invitation token expires', null=True)),
('acknowledgement_completed', models.BooleanField(default=False, help_text='User has completed acknowledgement wizard')),
('acknowledgement_completed_at', models.DateTimeField(blank=True, help_text='When acknowledgement was completed', null=True)),
('current_wizard_step', models.IntegerField(default=0, help_text='Current step in onboarding wizard')),
('wizard_completed_steps', models.JSONField(blank=True, default=list, help_text='List of completed wizard step IDs')),
],
options={
'ordering': ['-date_joined'],
},
),
migrations.CreateModel(
name='AcknowledgementChecklistItem',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('role', models.CharField(blank=True, choices=[('px_admin', 'PX Admin'), ('hospital_admin', 'Hospital Admin'), ('department_manager', 'Department Manager'), ('px_coordinator', 'PX Coordinator'), ('physician', 'Physician'), ('nurse', 'Nurse'), ('staff', 'Staff'), ('viewer', 'Viewer')], help_text='Target role for this item', max_length=50, null=True)),
('code', models.CharField(help_text='Unique code for this checklist item', max_length=100, unique=True)),
('text_en', models.CharField(max_length=500)),
('text_ar', models.CharField(blank=True, max_length=500)),
('description_en', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True)),
('is_required', models.BooleanField(default=True, help_text='Item must be acknowledged')),
('order', models.IntegerField(default=0, help_text='Display order in checklist')),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['role', 'order', 'code'],
},
),
migrations.CreateModel(
name='AcknowledgementContent',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('role', models.CharField(blank=True, choices=[('px_admin', 'PX Admin'), ('hospital_admin', 'Hospital Admin'), ('department_manager', 'Department Manager'), ('px_coordinator', 'PX Coordinator'), ('physician', 'Physician'), ('nurse', 'Nurse'), ('staff', 'Staff'), ('viewer', 'Viewer')], help_text='Target role for this content', max_length=50, null=True)),
('code', models.CharField(help_text='Unique code for this content section', max_length=100, unique=True)),
('title_en', models.CharField(max_length=200)),
('title_ar', models.CharField(blank=True, max_length=200)),
('description_en', models.TextField()),
('description_ar', models.TextField(blank=True)),
('content_en', models.TextField(blank=True)),
('content_ar', models.TextField(blank=True)),
('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-user', 'fa-shield')", max_length=50)),
('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#007bff')", max_length=7)),
('order', models.IntegerField(default=0, help_text='Display order in wizard')),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['role', 'order', 'code'],
},
),
migrations.CreateModel(
name='Role',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(choices=[('px_admin', 'PX Admin'), ('hospital_admin', 'Hospital Admin'), ('department_manager', 'Department Manager'), ('px_coordinator', 'PX Coordinator'), ('physician', 'Physician'), ('nurse', 'Nurse'), ('staff', 'Staff'), ('viewer', 'Viewer')], max_length=50, unique=True)),
('display_name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('level', models.IntegerField(default=0, help_text='Higher number = higher authority')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-level', 'name'],
},
),
migrations.CreateModel(
name='UserAcknowledgement',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_acknowledged', models.BooleanField(default=True)),
('acknowledged_at', models.DateTimeField(auto_now_add=True)),
('signature', models.TextField(blank=True, help_text='Digital signature data (base64 encoded)')),
('signature_ip', models.GenericIPAddressField(blank=True, help_text='IP address when signed', null=True)),
('signature_user_agent', models.TextField(blank=True, help_text='User agent when signed')),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata')),
],
options={
'ordering': ['-acknowledged_at'],
},
),
migrations.CreateModel(
name='UserProvisionalLog',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('event_type', models.CharField(choices=[('created', 'User Created'), ('invitation_sent', 'Invitation Sent'), ('invitation_resent', 'Invitation Resent'), ('wizard_started', 'Wizard Started'), ('step_completed', 'Wizard Step Completed'), ('wizard_completed', 'Wizard Completed'), ('user_activated', 'User Activated'), ('invitation_expired', 'Invitation Expired')], db_index=True, max_length=50)),
('description', models.TextField()),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional event data')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@ -1,117 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0001_initial'),
('auth', '0012_alter_user_first_name_max_length'),
('organizations', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='department',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='organizations.department'),
),
migrations.AddField(
model_name='user',
name='groups',
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups'),
),
migrations.AddField(
model_name='user',
name='hospital',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='organizations.hospital'),
),
migrations.AddField(
model_name='user',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'),
),
migrations.AddIndex(
model_name='acknowledgementcontent',
index=models.Index(fields=['role', 'is_active', 'order'], name='accounts_ac_role_6fe1fd_idx'),
),
migrations.AddIndex(
model_name='acknowledgementcontent',
index=models.Index(fields=['code'], name='accounts_ac_code_48fa92_idx'),
),
migrations.AddField(
model_name='acknowledgementchecklistitem',
name='content',
field=models.ForeignKey(blank=True, help_text='Related content section', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checklist_items', to='accounts.acknowledgementcontent'),
),
migrations.AddField(
model_name='role',
name='group',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='role_config', to='auth.group'),
),
migrations.AddField(
model_name='role',
name='permissions',
field=models.ManyToManyField(blank=True, to='auth.permission'),
),
migrations.AddField(
model_name='useracknowledgement',
name='checklist_item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_acknowledgements', to='accounts.acknowledgementchecklistitem'),
),
migrations.AddField(
model_name='useracknowledgement',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acknowledgements', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='userprovisionallog',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='provisional_logs', to=settings.AUTH_USER_MODEL),
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['email'], name='accounts_us_email_74c8d6_idx'),
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['employee_id'], name='accounts_us_employe_0cbd94_idx'),
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['is_active', '-date_joined'], name='accounts_us_is_acti_a32178_idx'),
),
migrations.AddIndex(
model_name='acknowledgementchecklistitem',
index=models.Index(fields=['role', 'is_active', 'order'], name='accounts_ac_role_c556c1_idx'),
),
migrations.AddIndex(
model_name='acknowledgementchecklistitem',
index=models.Index(fields=['code'], name='accounts_ac_code_b745de_idx'),
),
migrations.AddIndex(
model_name='useracknowledgement',
index=models.Index(fields=['user', '-acknowledged_at'], name='accounts_us_user_id_7ba948_idx'),
),
migrations.AddIndex(
model_name='useracknowledgement',
index=models.Index(fields=['checklist_item', '-acknowledged_at'], name='accounts_us_checkli_870e26_idx'),
),
migrations.AlterUniqueTogether(
name='useracknowledgement',
unique_together={('user', 'checklist_item')},
),
migrations.AddIndex(
model_name='userprovisionallog',
index=models.Index(fields=['user', '-created_at'], name='accounts_us_user_id_c488d5_idx'),
),
migrations.AddIndex(
model_name='userprovisionallog',
index=models.Index(fields=['event_type', '-created_at'], name='accounts_us_event_t_b7f691_idx'),
),
]

View File

@ -1,30 +0,0 @@
# Generated migration to fix null username values
from django.db import migrations
def fix_null_username(apps, schema_editor):
"""Set username to email for users with null username"""
User = apps.get_model('accounts', 'User')
# Update all users with null username to use their email
for user in User.objects.filter(username__isnull=True):
user.username = user.email
user.save(update_fields=['username'])
def reverse_fix_null_username(apps, schema_editor):
"""Reverse migration: set username back to None"""
User = apps.get_model('accounts', 'User')
User.objects.all().update(username=None)
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_initial'),
]
operations = [
migrations.RunPython(fix_null_username, reverse_fix_null_username),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_fix_null_username'),
]
operations = [
migrations.AlterField(
model_name='user',
name='username',
field=models.CharField(blank=True, default='', max_length=150),
),
]

View File

@ -98,6 +98,36 @@ class User(AbstractUser, TimeStampedModel):
default='en'
)
# Notification preferences
notification_email_enabled = models.BooleanField(
default=True,
help_text="Enable email notifications"
)
notification_sms_enabled = models.BooleanField(
default=False,
help_text="Enable SMS notifications"
)
preferred_notification_channel = models.CharField(
max_length=10,
choices=[
('email', 'Email'),
('sms', 'SMS'),
('both', 'Both')
],
default='email',
help_text="Preferred notification channel for general notifications"
)
explanation_notification_channel = models.CharField(
max_length=10,
choices=[
('email', 'Email'),
('sms', 'SMS'),
('both', 'Both')
],
default='email',
help_text="Preferred channel for explanation requests"
)
# Status
is_active = models.BooleanField(default=True)

View File

@ -117,6 +117,8 @@ class ProvisionalUserSerializer(serializers.ModelSerializer):
roles = serializers.ListField(
child=serializers.CharField(),
write_only=True,
required=False,
default=list,
help_text="List of role names to assign"
)

View File

@ -9,6 +9,8 @@ from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils import timezone
from django.db import models as db_models
from .models import (
AcknowledgementChecklistItem,
AcknowledgementContent,
@ -111,14 +113,26 @@ class OnboardingService:
Returns:
QuerySet of AcknowledgementContent
"""
# Get user's role
# Get user's role - convert group name to role code
role = None
if user.groups.exists():
role = user.groups.first().name
group_name = user.groups.first().name
# Map group names to role codes
role_mapping = {
'PX Admin': 'px_admin',
'Hospital Admin': 'hospital_admin',
'Department Manager': 'department_manager',
'PX Coordinator': 'px_coordinator',
'Physician': 'physician',
'Nurse': 'nurse',
'Staff': 'staff',
'Viewer': 'viewer',
}
role = role_mapping.get(group_name, group_name.lower().replace(' ', '_'))
# Get content for user's role or all roles
content = AcknowledgementContent.objects.filter(is_active=True).filter(
models.Q(role=role) | models.Q(role__isnull=True)
db_models.Q(role=role) | db_models.Q(role__isnull=True)
).order_by('order')
return content
@ -134,16 +148,26 @@ class OnboardingService:
Returns:
QuerySet of AcknowledgementChecklistItem
"""
from django.db import models
# Get user's role
# Get user's role - convert group name to role code
role = None
if user.groups.exists():
role = user.groups.first().name
group_name = user.groups.first().name
# Map group names to role codes
role_mapping = {
'PX Admin': 'px_admin',
'Hospital Admin': 'hospital_admin',
'Department Manager': 'department_manager',
'PX Coordinator': 'px_coordinator',
'Physician': 'physician',
'Nurse': 'nurse',
'Staff': 'staff',
'Viewer': 'viewer',
}
role = role_mapping.get(group_name, group_name.lower().replace(' ', '_'))
# Get items for user's role or all roles
items = AcknowledgementChecklistItem.objects.filter(is_active=True).filter(
models.Q(role=role) | models.Q(role__isnull=True)
db_models.Q(role=role) | db_models.Q(role__isnull=True)
).order_by('order')
return items
@ -452,7 +476,7 @@ class EmailService:
Boolean indicating success
"""
base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000')
user_detail_url = f"{base_url}/accounts/management/progress/{user.id}/"
user_detail_url = f"{base_url}/accounts/onboarding/provisional/{user.id}/progress/"
# Render email content
context = {

View File

@ -9,6 +9,7 @@ from .views import (
RoleViewSet,
UserAcknowledgementViewSet,
UserViewSet,
user_settings,
)
from .ui_views import (
acknowledgement_checklist_list,
@ -40,6 +41,7 @@ urlpatterns = [
# 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/<uidb64>/<token>/', CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('password/change/', change_password_view, name='password_change'),

View File

@ -1,7 +1,11 @@
"""
Accounts views and viewsets
"""
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect
from django.utils.translation import gettext as _
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
@ -274,6 +278,90 @@ class RoleViewSet(viewsets.ModelViewSet):
return super().get_queryset().select_related('group')
# ==================== Settings Views ====================
@login_required
def user_settings(request):
"""
User settings page for managing notification preferences, profile, and security.
"""
user = request.user
if request.method == 'POST':
# Get form type
form_type = request.POST.get('form_type', 'preferences')
if form_type == 'preferences':
# Update notification preferences
user.notification_email_enabled = request.POST.get('notification_email_enabled', 'off') == 'on'
user.notification_sms_enabled = request.POST.get('notification_sms_enabled', 'off') == 'on'
user.preferred_notification_channel = request.POST.get('preferred_notification_channel', 'email')
user.explanation_notification_channel = request.POST.get('explanation_notification_channel', 'email')
user.phone = request.POST.get('phone', '')
user.language = request.POST.get('language', 'en')
messages.success(request, _('Notification preferences updated successfully.'))
elif form_type == 'profile':
# Update profile information
user.first_name = request.POST.get('first_name', '')
user.last_name = request.POST.get('last_name', '')
user.phone = request.POST.get('phone', '')
user.bio = request.POST.get('bio', '')
# Handle avatar upload
if request.FILES.get('avatar'):
user.avatar = request.FILES.get('avatar')
messages.success(request, _('Profile updated successfully.'))
elif form_type == 'password':
# Change password
current_password = request.POST.get('current_password')
new_password = request.POST.get('new_password')
confirm_password = request.POST.get('confirm_password')
if not user.check_password(current_password):
messages.error(request, _('Current password is incorrect.'))
elif new_password != confirm_password:
messages.error(request, _('New passwords do not match.'))
elif len(new_password) < 8:
messages.error(request, _('Password must be at least 8 characters long.'))
else:
user.set_password(new_password)
messages.success(request, _('Password changed successfully. Please login again.'))
# Re-authenticate user with new password
from django.contrib.auth import update_session_auth_hash
update_session_auth_hash(request, user)
user.save()
# Log the update
AuditService.log_from_request(
event_type='other',
description=f"User {user.email} updated settings",
request=request,
content_object=user
)
return redirect('accounts:settings')
context = {
'user': user,
'notification_channels': [
('email', _('Email')),
('sms', _('SMS')),
('both', _('Both'))
],
'languages': [
('en', _('English')),
('ar', _('Arabic'))
]
}
return render(request, 'accounts/settings.html', context)
# ==================== Onboarding ViewSets ====================
class AcknowledgementContentViewSet(viewsets.ModelViewSet):

View File

@ -1,43 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='SentimentResult',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('object_id', models.UUIDField()),
('text', models.TextField(help_text='Text that was analyzed')),
('language', models.CharField(choices=[('en', 'English'), ('ar', 'Arabic')], default='en', max_length=5)),
('sentiment', models.CharField(choices=[('positive', 'Positive'), ('neutral', 'Neutral'), ('negative', 'Negative')], db_index=True, max_length=20)),
('sentiment_score', models.DecimalField(decimal_places=4, help_text='Sentiment score from -1 (negative) to 1 (positive)', max_digits=5)),
('confidence', models.DecimalField(decimal_places=4, help_text='Confidence level of the sentiment analysis', max_digits=5)),
('ai_service', models.CharField(default='stub', help_text="AI service used (e.g., 'openai', 'azure', 'aws', 'stub')", max_length=100)),
('ai_model', models.CharField(blank=True, help_text='Specific AI model used', max_length=100)),
('processing_time_ms', models.IntegerField(blank=True, help_text='Time taken to analyze (milliseconds)', null=True)),
('keywords', models.JSONField(blank=True, default=list, help_text='Extracted keywords')),
('entities', models.JSONField(blank=True, default=list, help_text='Extracted entities (people, places, etc.)')),
('emotions', models.JSONField(blank=True, default=dict, help_text='Emotion scores (joy, anger, sadness, etc.)')),
('metadata', models.JSONField(blank=True, default=dict)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['sentiment', '-created_at'], name='ai_engine_s_sentime_e4f801_idx'), models.Index(fields=['content_type', 'object_id'], name='ai_engine_s_content_eb5a8a_idx')],
},
),
]

View File

@ -1,61 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='KPI',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200, unique=True)),
('name_ar', models.CharField(blank=True, max_length=200)),
('description', models.TextField(blank=True)),
('category', models.CharField(choices=[('patient_satisfaction', 'Patient Satisfaction'), ('complaint_management', 'Complaint Management'), ('action_management', 'Action Management'), ('sla_compliance', 'SLA Compliance'), ('survey_response', 'Survey Response'), ('operational', 'Operational')], db_index=True, max_length=100)),
('unit', models.CharField(help_text='Unit of measurement (%, count, hours, etc.)', max_length=50)),
('calculation_method', models.TextField(help_text='Description of how this KPI is calculated')),
('target_value', models.DecimalField(blank=True, decimal_places=2, help_text='Target value for this KPI', max_digits=10, null=True)),
('warning_threshold', models.DecimalField(blank=True, decimal_places=2, help_text='Warning threshold', max_digits=10, null=True)),
('critical_threshold', models.DecimalField(blank=True, decimal_places=2, help_text='Critical threshold', max_digits=10, null=True)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'KPI',
'verbose_name_plural': 'KPIs',
'ordering': ['category', 'name'],
},
),
migrations.CreateModel(
name='KPIValue',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('value', models.DecimalField(decimal_places=2, max_digits=10)),
('period_start', models.DateTimeField(db_index=True)),
('period_end', models.DateTimeField(db_index=True)),
('period_type', models.CharField(choices=[('hourly', 'Hourly'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly'), ('quarterly', 'Quarterly'), ('yearly', 'Yearly')], default='daily', max_length=20)),
('status', models.CharField(choices=[('on_target', 'On Target'), ('warning', 'Warning'), ('critical', 'Critical')], db_index=True, max_length=20)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional calculation details')),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kpi_values', to='organizations.department')),
('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kpi_values', to='organizations.hospital')),
('kpi', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='analytics.kpi')),
],
options={
'ordering': ['-period_end'],
'indexes': [models.Index(fields=['kpi', '-period_end'], name='analytics_k_kpi_id_f9c38d_idx'), models.Index(fields=['hospital', 'kpi', '-period_end'], name='analytics_k_hospita_356dca_idx')],
},
),
]

View File

@ -229,8 +229,9 @@ class UnifiedAnalyticsService:
'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(SocialMediaComment.objects.filter(
sentiment='negative',
ai_analysis__sentiment='negative',
published_at__gte=start_date,
published_at__lte=end_date
).count()),

View File

@ -0,0 +1,40 @@
from django.core.management.base import BaseCommand
from apps.appreciation.models import AppreciationCategory
class Command(BaseCommand):
help = 'Create Patient Feedback Appreciation category'
def handle(self, *args, **options):
# Check if category already exists
existing = AppreciationCategory.objects.filter(
code='patient_feedback'
).first()
if existing:
self.stdout.write(
self.style.WARNING(
f'Category "Patient Feedback Appreciation" already exists.'
)
)
return
# Create the category
category = AppreciationCategory.objects.create(
hospital=None, # System-wide category
code='patient_feedback',
name_en='Patient Feedback Appreciation',
name_ar='تقدير ملاحظات المرضى',
description_en='Appreciation received from patient feedback',
description_ar='تقدير مستلم من ملاحظات المرضى',
icon='bi-heart',
color='#388e3c',
order=100,
is_active=True
)
self.stdout.write(
self.style.SUCCESS(
f'Successfully created "Patient Feedback Appreciation" category (ID: {category.id})'
)
)

View File

@ -1,185 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AppreciationBadge',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(help_text='Unique badge code', max_length=50, unique=True)),
('name_en', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('description_en', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True)),
('icon', models.CharField(blank=True, help_text='Icon class', max_length=50)),
('color', models.CharField(blank=True, help_text='Hex color code', max_length=7)),
('criteria_type', models.CharField(choices=[('received_count', 'Total Appreciations Received'), ('received_month', 'Appreciations Received in a Month'), ('streak_weeks', 'Consecutive Weeks with Appreciation'), ('diverse_senders', 'Appreciations from Different Senders')], db_index=True, max_length=50)),
('criteria_value', models.IntegerField(help_text='Value to achieve (e.g., 10 for 10 appreciations)')),
('order', models.IntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide badges', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_badges', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'order', 'name_en'],
},
),
migrations.CreateModel(
name='AppreciationCategory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(help_text='Unique code for this category', max_length=50)),
('name_en', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('description_en', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True)),
('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-heart', 'fa-star')", max_length=50)),
('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#FF5733')", max_length=7)),
('order', models.IntegerField(default=0, help_text='Display order')),
('is_active', models.BooleanField(default=True)),
('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide categories', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_categories', to='organizations.hospital')),
],
options={
'verbose_name_plural': 'Appreciation Categories',
'ordering': ['hospital', 'order', 'name_en'],
},
),
migrations.CreateModel(
name='Appreciation',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('recipient_object_id', models.UUIDField(blank=True, null=True)),
('message_en', models.TextField()),
('message_ar', models.TextField(blank=True)),
('visibility', models.CharField(choices=[('private', 'Private'), ('department', 'Department'), ('hospital', 'Hospital'), ('public', 'Public')], db_index=True, default='private', max_length=20)),
('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent'), ('acknowledged', 'Acknowledged')], db_index=True, default='draft', max_length=20)),
('is_anonymous', models.BooleanField(default=False, help_text='Hide sender identity from recipient')),
('sent_at', models.DateTimeField(blank=True, null=True)),
('acknowledged_at', models.DateTimeField(blank=True, null=True)),
('notification_sent', models.BooleanField(default=False)),
('notification_sent_at', models.DateTimeField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('department', models.ForeignKey(blank=True, help_text='Department context (if applicable)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appreciations', to='organizations.hospital')),
('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_recipients', to='contenttypes.contenttype')),
('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_appreciations', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='appreciation.appreciationcategory')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AppreciationStats',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('recipient_object_id', models.UUIDField(blank=True, null=True)),
('year', models.IntegerField(db_index=True)),
('month', models.IntegerField(db_index=True, help_text='1-12')),
('received_count', models.IntegerField(default=0)),
('sent_count', models.IntegerField(default=0)),
('acknowledged_count', models.IntegerField(default=0)),
('hospital_rank', models.IntegerField(blank=True, help_text='Rank within hospital', null=True)),
('department_rank', models.IntegerField(blank=True, help_text='Rank within department', null=True)),
('category_breakdown', models.JSONField(blank=True, default=dict, help_text='Breakdown by category ID and count')),
('metadata', models.JSONField(blank=True, default=dict)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_stats', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_stats', to='organizations.hospital')),
('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_stats_recipients', to='contenttypes.contenttype')),
],
options={
'ordering': ['-year', '-month', '-received_count'],
},
),
migrations.CreateModel(
name='UserBadge',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('recipient_object_id', models.UUIDField(blank=True, null=True)),
('earned_at', models.DateTimeField(auto_now_add=True)),
('appreciation_count', models.IntegerField(help_text='Count when badge was earned')),
('metadata', models.JSONField(blank=True, default=dict)),
('badge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='earned_by', to='appreciation.appreciationbadge')),
('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='earned_badges_recipients', to='contenttypes.contenttype')),
],
options={
'ordering': ['-earned_at'],
},
),
migrations.AddIndex(
model_name='appreciationbadge',
index=models.Index(fields=['hospital', 'is_active'], name='appreciatio_hospita_3847f7_idx'),
),
migrations.AddIndex(
model_name='appreciationbadge',
index=models.Index(fields=['code'], name='appreciatio_code_416153_idx'),
),
migrations.AddIndex(
model_name='appreciationcategory',
index=models.Index(fields=['hospital', 'is_active'], name='appreciatio_hospita_b8e413_idx'),
),
migrations.AddIndex(
model_name='appreciationcategory',
index=models.Index(fields=['code'], name='appreciatio_code_50215a_idx'),
),
migrations.AlterUniqueTogether(
name='appreciationcategory',
unique_together={('hospital', 'code')},
),
migrations.AddIndex(
model_name='appreciation',
index=models.Index(fields=['status', '-created_at'], name='appreciatio_status_24158d_idx'),
),
migrations.AddIndex(
model_name='appreciation',
index=models.Index(fields=['hospital', 'status'], name='appreciatio_hospita_db3f34_idx'),
),
migrations.AddIndex(
model_name='appreciation',
index=models.Index(fields=['recipient_content_type', 'recipient_object_id', '-created_at'], name='appreciatio_recipie_71ef0e_idx'),
),
migrations.AddIndex(
model_name='appreciation',
index=models.Index(fields=['visibility', '-created_at'], name='appreciatio_visibil_ed96d9_idx'),
),
migrations.AddIndex(
model_name='appreciationstats',
index=models.Index(fields=['hospital', 'year', 'month', '-received_count'], name='appreciatio_hospita_a0d454_idx'),
),
migrations.AddIndex(
model_name='appreciationstats',
index=models.Index(fields=['department', 'year', 'month', '-received_count'], name='appreciatio_departm_f68345_idx'),
),
migrations.AlterUniqueTogether(
name='appreciationstats',
unique_together={('recipient_content_type', 'recipient_object_id', 'year', 'month')},
),
migrations.AddIndex(
model_name='userbadge',
index=models.Index(fields=['recipient_content_type', 'recipient_object_id', '-earned_at'], name='appreciatio_recipie_fc90c8_idx'),
),
]

View File

@ -1 +0,0 @@
# Migrations module

View File

@ -49,8 +49,7 @@ def send_appreciation_notification(appreciation):
Uses the notification system to send email/SMS/WhatsApp.
"""
try:
from apps.notifications.models import NotificationLog, NotificationChannel, NotificationStatus
from apps.notifications.services import send_notification
from apps.notifications.services import send_email, send_sms
# Get recipient details
recipient_email = appreciation.get_recipient_email()
@ -73,12 +72,11 @@ def send_appreciation_notification(appreciation):
# Send email if available
if recipient_email:
try:
send_notification(
channel=NotificationChannel.EMAIL,
recipient=recipient_email,
send_email(
email=recipient_email,
subject=f"New Appreciation Received - {appreciation.hospital.name}",
message=message_en,
content_object=appreciation,
related_object=appreciation,
)
except Exception as e:
# Log error but don't fail
@ -87,11 +85,10 @@ def send_appreciation_notification(appreciation):
# Send SMS if available
if recipient_phone:
try:
send_notification(
channel=NotificationChannel.SMS,
recipient=recipient_phone,
send_sms(
phone=recipient_phone,
message=message_en,
content_object=appreciation,
related_object=appreciation,
)
except Exception as e:
# Log error but don't fail

View File

@ -917,6 +917,30 @@ def get_staff_by_hospital(request):
return JsonResponse({'staff': results})
@login_required
def get_physicians_by_hospital(request):
"""Get physicians for a hospital (AJAX)"""
hospital_id = request.GET.get('hospital_id')
if not hospital_id:
return JsonResponse({'physicians': []})
physicians = Staff.objects.filter(
hospital_id=hospital_id,
status='active',
staff_type='physician'
).values('id', 'user__first_name', 'user__last_name')
results = [
{
'id': str(p['id']),
'name': f"{p['user__first_name']} {p['user__last_name']}",
}
for p in physicians
]
return JsonResponse({'physicians': results})
@login_required
def get_departments_by_hospital(request):
"""Get departments for a hospital (AJAX)"""

View File

@ -52,6 +52,7 @@ urlpatterns = [
# AJAX Helpers
path('ajax/users/', ui_views.get_users_by_hospital, name='get_users_by_hospital'),
path('ajax/staff/', ui_views.get_staff_by_hospital, name='get_staff_by_hospital'),
path('ajax/physicians/', ui_views.get_physicians_by_hospital, name='get_physicians_by_hospital'),
path('ajax/departments/', ui_views.get_departments_by_hospital, name='get_departments_by_hospital'),
path('ajax/summary/', ui_views.appreciation_summary_ajax, name='appreciation_summary_ajax'),
]

View File

@ -1,50 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CallCenterInteraction',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('caller_name', models.CharField(blank=True, max_length=200)),
('caller_phone', models.CharField(blank=True, max_length=20)),
('caller_relationship', models.CharField(choices=[('patient', 'Patient'), ('family', 'Family Member'), ('other', 'Other')], default='patient', max_length=50)),
('call_type', models.CharField(choices=[('inquiry', 'Inquiry'), ('complaint', 'Complaint'), ('appointment', 'Appointment'), ('follow_up', 'Follow-up'), ('feedback', 'Feedback'), ('other', 'Other')], db_index=True, max_length=50)),
('subject', models.CharField(max_length=500)),
('notes', models.TextField(blank=True)),
('wait_time_seconds', models.IntegerField(blank=True, help_text='Time caller waited before agent answered', null=True)),
('call_duration_seconds', models.IntegerField(blank=True, help_text='Total call duration', null=True)),
('satisfaction_rating', models.IntegerField(blank=True, help_text='Caller satisfaction rating (1-5)', null=True)),
('is_low_rating', models.BooleanField(db_index=True, default=False, help_text='True if rating below threshold (< 3)')),
('resolved', models.BooleanField(default=False)),
('resolution_notes', models.TextField(blank=True)),
('call_started_at', models.DateTimeField(auto_now_add=True)),
('call_ended_at', models.DateTimeField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('agent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='call_center_interactions', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='call_center_interactions', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='call_center_interactions', to='organizations.hospital')),
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='call_center_interactions', to='organizations.patient')),
],
options={
'ordering': ['-call_started_at'],
'indexes': [models.Index(fields=['hospital', '-call_started_at'], name='callcenter__hospita_108d22_idx'), models.Index(fields=['agent', '-call_started_at'], name='callcenter__agent_i_51efd4_idx'), models.Index(fields=['is_low_rating', '-call_started_at'], name='callcenter__is_low__cbe9c7_idx')],
},
),
]

View File

@ -37,7 +37,7 @@ class ComplaintUpdateInline(admin.TabularInline):
class ComplaintAdmin(admin.ModelAdmin):
"""Complaint admin"""
list_display = [
'title_preview', 'patient', 'hospital', 'category',
'title_preview', 'complaint_type_badge', 'patient', 'hospital', 'category',
'severity_badge', 'status_badge', 'sla_indicator',
'created_by', 'assigned_to', 'created_at'
]
@ -61,7 +61,7 @@ class ComplaintAdmin(admin.ModelAdmin):
'fields': ('hospital', 'department', 'staff')
}),
('Complaint Details', {
'fields': ('title', 'description', 'category', 'subcategory')
'fields': ('complaint_type', 'title', 'description', 'category', 'subcategory')
}),
('Classification', {
'fields': ('priority', 'severity', 'source')
@ -139,6 +139,20 @@ class ComplaintAdmin(admin.ModelAdmin):
)
status_badge.short_description = 'Status'
def complaint_type_badge(self, obj):
"""Display complaint type with color badge"""
colors = {
'complaint': 'danger',
'appreciation': 'success',
}
color = colors.get(obj.complaint_type, 'secondary')
return format_html(
'<span class="badge bg-{}">{}</span>',
color,
obj.get_complaint_type_display()
)
complaint_type_badge.short_description = 'Type'
def sla_indicator(self, obj):
"""Display SLA status"""
if obj.is_overdue:

View File

@ -1,6 +1,8 @@
"""
Complaints forms
"""
import os
from django import forms
from django.db import models
from django.core.exceptions import ValidationError
@ -12,7 +14,10 @@ from apps.complaints.models import (
ComplaintCategory,
ComplaintSource,
ComplaintStatus,
Inquiry
Inquiry,
ComplaintSLAConfig,
EscalationRule,
ComplaintThreshold,
)
from apps.core.models import PriorityChoices, SeverityChoices
from apps.organizations.models import Department, Hospital, Patient, Staff
@ -22,7 +27,7 @@ class MultiFileInput(forms.FileInput):
"""
Custom FileInput widget that supports multiple file uploads.
Unlike the standard FileInput which only supports single files,
Unlike standard FileInput which only supports single files,
this widget allows users to upload multiple files at once.
"""
def __init__(self, attrs=None):
@ -154,7 +159,7 @@ class PublicComplaintForm(forms.ModelForm):
)
)
# Hidden fields - these will be set by the view or AI
# Hidden fields - these will be set by view or AI
severity = forms.ChoiceField(
label=_("Severity"),
choices=SeverityChoices.choices,
@ -233,7 +238,6 @@ class PublicComplaintForm(forms.ModelForm):
# Check file type
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx']
import os
ext = os.path.splitext(file.name)[1].lower()
if ext not in allowed_extensions:
raise ValidationError(_('Allowed file types: JPG, PNG, GIF, PDF, DOC, DOCX'))
@ -314,18 +318,16 @@ class ComplaintForm(forms.ModelForm):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospital and patient by user permissions
# Filter hospitals and patients based on user permissions
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(
id=user.hospital.id
)
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['patient'].queryset = Patient.objects.filter(
primary_hospital=user.hospital,
status='active'
)
# Check for hospital selection in both initial data and POST data
# This is needed for validation to work correctly
hospital_id = None
if 'hospital' in self.data:
hospital_id = self.data.get('hospital')
@ -421,11 +423,11 @@ class InquiryForm(forms.ModelForm):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospital by user permissions
# Filter hospitals based on user role
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(
id=user.hospital.id
)
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['hospital'].widget.attrs['readonly'] = True
# Check for hospital selection in both initial data and POST data
hospital_id = None
@ -442,7 +444,488 @@ class InquiryForm(forms.ModelForm):
).order_by('name')
class SLAConfigForm(forms.ModelForm):
"""Form for creating and editing SLA configurations"""
class Meta:
model = ComplaintSLAConfig
fields = ['hospital', 'severity', 'priority', 'sla_hours', 'reminder_hours_before', 'is_active']
widgets = {
'hospital': forms.Select(attrs={'class': 'form-select'}),
'severity': forms.Select(attrs={'class': 'form-select'}),
'priority': forms.Select(attrs={'class': 'form-select'}),
'sla_hours': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}),
'reminder_hours_before': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals based on user role
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['hospital'].widget.attrs['readonly'] = True
def clean(self):
cleaned_data = super().clean()
hospital = cleaned_data.get('hospital')
severity = cleaned_data.get('severity')
priority = cleaned_data.get('priority')
sla_hours = cleaned_data.get('sla_hours')
reminder_hours = cleaned_data.get('reminder_hours_before')
# Validate SLA hours is positive
if sla_hours and sla_hours <= 0:
raise ValidationError({'sla_hours': 'SLA hours must be greater than 0'})
# Validate reminder hours < SLA hours
if sla_hours and reminder_hours and reminder_hours >= sla_hours:
raise ValidationError({'reminder_hours_before': 'Reminder hours must be less than SLA hours'})
# Check for unique combination (excluding current instance when editing)
if hospital and severity and priority:
queryset = ComplaintSLAConfig.objects.filter(
hospital=hospital,
severity=severity,
priority=priority
)
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise ValidationError(
'An SLA configuration for this hospital, severity, and priority already exists.'
)
return cleaned_data
class EscalationRuleForm(forms.ModelForm):
"""Form for creating and editing escalation rules"""
class Meta:
model = EscalationRule
fields = [
'hospital', 'name', 'description', 'escalation_level', 'max_escalation_level',
'trigger_on_overdue', 'trigger_hours_overdue',
'reminder_escalation_enabled', 'reminder_escalation_hours',
'escalate_to_role', 'escalate_to_user',
'severity_filter', 'priority_filter', 'is_active'
]
widgets = {
'hospital': forms.Select(attrs={'class': 'form-select'}),
'name': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'escalation_level': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}),
'max_escalation_level': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}),
'trigger_on_overdue': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'trigger_hours_overdue': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'reminder_escalation_enabled': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'reminder_escalation_hours': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'escalate_to_role': forms.Select(attrs={'class': 'form-select', 'id': 'escalate_to_role'}),
'escalate_to_user': forms.Select(attrs={'class': 'form-select'}),
'severity_filter': forms.Select(attrs={'class': 'form-select'}),
'priority_filter': forms.Select(attrs={'class': 'form-select'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals based on user role
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['hospital'].widget.attrs['readonly'] = True
# Filter users for escalate_to_user field
from apps.accounts.models import User
if user and user.is_px_admin():
self.fields['escalate_to_user'].queryset = User.objects.filter(is_active=True)
elif user and user.hospital:
self.fields['escalate_to_user'].queryset = User.objects.filter(
is_active=True,
hospital=user.hospital
)
else:
self.fields['escalate_to_user'].queryset = User.objects.none()
def clean(self):
cleaned_data = super().clean()
escalate_to_role = cleaned_data.get('escalate_to_role')
escalate_to_user = cleaned_data.get('escalate_to_user')
# If role is 'specific_user', user must be specified
if escalate_to_role == 'specific_user' and not escalate_to_user:
raise ValidationError({'escalate_to_user': 'Please select a user when role is set to Specific User'})
return cleaned_data
class ComplaintThresholdForm(forms.ModelForm):
"""Form for creating and editing complaint thresholds"""
class Meta:
model = ComplaintThreshold
fields = ['hospital', 'threshold_type', 'threshold_value', 'comparison_operator', 'action_type', 'is_active']
widgets = {
'hospital': forms.Select(attrs={'class': 'form-select'}),
'threshold_type': forms.Select(attrs={'class': 'form-select'}),
'threshold_value': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'comparison_operator': forms.Select(attrs={'class': 'form-select'}),
'action_type': forms.Select(attrs={'class': 'form-select'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals based on user role
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['hospital'].widget.attrs['readonly'] = True
class ComplaintForm(forms.ModelForm):
"""
Form for creating complaints by authenticated users.
Uses Django form rendering with minimal JavaScript for dependent dropdowns.
Category, subcategory, and source are omitted - AI will determine them.
"""
patient = forms.ModelChoiceField(
label=_("Patient"),
queryset=Patient.objects.filter(status='active'),
empty_label=_("Select Patient"),
required=True,
widget=forms.Select(attrs={'class': 'form-select', 'id': 'patientSelect'})
)
hospital = forms.ModelChoiceField(
label=_("Hospital"),
queryset=Hospital.objects.filter(status='active'),
empty_label=_("Select Hospital"),
required=True,
widget=forms.Select(attrs={'class': 'form-select', 'id': 'hospitalSelect'})
)
department = forms.ModelChoiceField(
label=_("Department"),
queryset=Department.objects.none(),
empty_label=_("Select Department"),
required=False,
widget=forms.Select(attrs={'class': 'form-select', 'id': 'departmentSelect'})
)
staff = forms.ModelChoiceField(
label=_("Staff"),
queryset=Staff.objects.none(),
empty_label=_("Select Staff"),
required=False,
widget=forms.Select(attrs={'class': 'form-select', 'id': 'staffSelect'})
)
encounter_id = forms.CharField(
label=_("Encounter ID"),
required=False,
widget=forms.TextInput(attrs={'class': 'form-control',
'placeholder': _('Optional encounter/visit ID')})
)
description = forms.CharField(
label=_("Description"),
required=True,
widget=forms.Textarea(attrs={'class': 'form-control',
'rows': 6,
'placeholder': _('Detailed description of complaint...')})
)
class Meta:
model = Complaint
fields = ['patient', 'hospital', 'department', 'staff',
'encounter_id', 'description']
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals and patients based on user permissions
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['patient'].queryset = Patient.objects.filter(
primary_hospital=user.hospital,
status='active'
)
# Check for hospital selection in both initial data and POST data
hospital_id = None
if 'hospital' in self.data:
hospital_id = self.data.get('hospital')
elif 'hospital' in self.initial:
hospital_id = self.initial.get('hospital')
if hospital_id:
# Filter departments based on selected hospital
self.fields['department'].queryset = Department.objects.filter(
hospital_id=hospital_id,
status='active'
).order_by('name')
# Filter staff based on selected hospital
self.fields['staff'].queryset = Staff.objects.filter(
hospital_id=hospital_id,
status='active'
).order_by('first_name', 'last_name')
class InquiryForm(forms.ModelForm):
"""
Form for creating inquiries by authenticated users.
Similar to ComplaintForm - supports patient search, department filtering,
and proper field validation with AJAX support.
"""
patient = forms.ModelChoiceField(
label=_("Patient (Optional)"),
queryset=Patient.objects.filter(status='active'),
empty_label=_("Select Patient"),
required=False,
widget=forms.Select(attrs={'class': 'form-select', 'id': 'patientSelect'})
)
hospital = forms.ModelChoiceField(
label=_("Hospital"),
queryset=Hospital.objects.filter(status='active'),
empty_label=_("Select Hospital"),
required=True,
widget=forms.Select(attrs={'class': 'form-select', 'id': 'hospitalSelect'})
)
department = forms.ModelChoiceField(
label=_("Department (Optional)"),
queryset=Department.objects.none(),
empty_label=_("Select Department"),
required=False,
widget=forms.Select(attrs={'class': 'form-select', 'id': 'departmentSelect'})
)
category = forms.ChoiceField(
label=_("Inquiry Type"),
choices=[
('general', 'General Inquiry'),
('appointment', 'Appointment Related'),
('billing', 'Billing & Insurance'),
('medical_records', 'Medical Records'),
('pharmacy', 'Pharmacy'),
('insurance', 'Insurance'),
('feedback', 'Feedback'),
('other', 'Other'),
],
required=True,
widget=forms.Select(attrs={'class': 'form-control'})
)
subject = forms.CharField(
label=_("Subject"),
max_length=200,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Brief subject')})
)
message = forms.CharField(
label=_("Message"),
required=True,
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 5, 'placeholder': _('Describe your inquiry')})
)
# Contact info for inquiries without patient
contact_name = forms.CharField(label=_("Contact Name"), max_length=200, required=False, widget=forms.TextInput(attrs={'class': 'form-control'}))
contact_phone = forms.CharField(label=_("Contact Phone"), max_length=20, required=False, widget=forms.TextInput(attrs={'class': 'form-control'}))
contact_email = forms.EmailField(label=_("Contact Email"), required=False, widget=forms.EmailInput(attrs={'class': 'form-control'}))
class Meta:
model = Inquiry
fields = ['patient', 'hospital', 'department', 'subject', 'message',
'contact_name', 'contact_phone', 'contact_email']
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals based on user role
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['hospital'].widget.attrs['readonly'] = True
# Check for hospital selection in both initial data and POST data
hospital_id = None
if 'hospital' in self.data:
hospital_id = self.data.get('hospital')
elif 'hospital' in self.initial:
hospital_id = self.initial.get('hospital')
if hospital_id:
# Filter departments based on selected hospital
self.fields['department'].queryset = Department.objects.filter(
hospital_id=hospital_id,
status='active'
).order_by('name')
class SLAConfigForm(forms.ModelForm):
"""Form for creating and editing SLA configurations"""
class Meta:
model = ComplaintSLAConfig
fields = ['hospital', 'severity', 'priority', 'sla_hours', 'reminder_hours_before', 'is_active']
widgets = {
'hospital': forms.Select(attrs={'class': 'form-select'}),
'severity': forms.Select(attrs={'class': 'form-select'}),
'priority': forms.Select(attrs={'class': 'form-select'}),
'sla_hours': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}),
'reminder_hours_before': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals based on user role
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['hospital'].widget.attrs['readonly'] = True
def clean(self):
cleaned_data = super().clean()
hospital = cleaned_data.get('hospital')
severity = cleaned_data.get('severity')
priority = cleaned_data.get('priority')
sla_hours = cleaned_data.get('sla_hours')
reminder_hours = cleaned_data.get('reminder_hours_before')
# Validate SLA hours is positive
if sla_hours and sla_hours <= 0:
raise ValidationError({'sla_hours': 'SLA hours must be greater than 0'})
# Validate reminder hours < SLA hours
if sla_hours and reminder_hours and reminder_hours >= sla_hours:
raise ValidationError({'reminder_hours_before': 'Reminder hours must be less than SLA hours'})
# Check for unique combination (excluding current instance when editing)
if hospital and severity and priority:
queryset = ComplaintSLAConfig.objects.filter(
hospital=hospital,
severity=severity,
priority=priority
)
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise ValidationError(
'An SLA configuration for this hospital, severity, and priority already exists.'
)
return cleaned_data
class EscalationRuleForm(forms.ModelForm):
"""Form for creating and editing escalation rules"""
class Meta:
model = EscalationRule
fields = [
'hospital', 'name', 'description', 'escalation_level', 'max_escalation_level',
'trigger_on_overdue', 'trigger_hours_overdue',
'reminder_escalation_enabled', 'reminder_escalation_hours',
'escalate_to_role', 'escalate_to_user',
'severity_filter', 'priority_filter', 'is_active'
]
widgets = {
'hospital': forms.Select(attrs={'class': 'form-select'}),
'name': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'escalation_level': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}),
'max_escalation_level': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}),
'trigger_on_overdue': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'trigger_hours_overdue': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'reminder_escalation_enabled': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'reminder_escalation_hours': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'escalate_to_role': forms.Select(attrs={'class': 'form-select', 'id': 'escalate_to_role'}),
'escalate_to_user': forms.Select(attrs={'class': 'form-select'}),
'severity_filter': forms.Select(attrs={'class': 'form-select'}),
'priority_filter': forms.Select(attrs={'class': 'form-select'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals based on user role
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['hospital'].widget.attrs['readonly'] = True
# Filter users for escalate_to_user field
from apps.accounts.models import User
if user and user.is_px_admin():
self.fields['escalate_to_user'].queryset = User.objects.filter(is_active=True)
elif user and user.hospital:
self.fields['escalate_to_user'].queryset = User.objects.filter(
is_active=True,
hospital=user.hospital
)
else:
self.fields['escalate_to_user'].queryset = User.objects.none()
def clean(self):
cleaned_data = super().clean()
escalate_to_role = cleaned_data.get('escalate_to_role')
escalate_to_user = cleaned_data.get('escalate_to_user')
# If role is 'specific_user', user must be specified
if escalate_to_role == 'specific_user' and not escalate_to_user:
raise ValidationError({'escalate_to_user': 'Please select a user when role is set to Specific User'})
return cleaned_data
class ComplaintThresholdForm(forms.ModelForm):
"""Form for creating and editing complaint thresholds"""
class Meta:
model = ComplaintThreshold
fields = ['hospital', 'threshold_type', 'threshold_value', 'comparison_operator', 'action_type', 'is_active']
widgets = {
'hospital': forms.Select(attrs={'class': 'form-select'}),
'threshold_type': forms.Select(attrs={'class': 'form-select'}),
'threshold_value': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'comparison_operator': forms.Select(attrs={'class': 'form-select'}),
'action_type': forms.Select(attrs={'class': 'form-select'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals based on user role
if user and not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital
self.fields['hospital'].widget.attrs['readonly'] = True
class PublicInquiryForm(forms.Form):

View File

@ -0,0 +1,570 @@
"""
Management command to seed complaint data with bilingual support (English and Arabic)
"""
import random
import uuid
from datetime import timedelta
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, ComplaintCategory, ComplaintUpdate
from apps.organizations.models import Hospital, Department, Staff
from apps.px_sources.models import PXSource
# English complaint templates
ENGLISH_COMPLAINTS = {
'staff_mentioned': [
{
'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. Her attitude made my hospital experience very unpleasant.',
'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. This negligence is unacceptable and needs to be addressed immediately.',
'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. This level of neglect is unacceptable in a healthcare setting.',
'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. I felt dismissed and anxious about my treatment.',
'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 that needs immediate investigation.',
'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. This poor customer service reflects badly on the hospital.',
'category': 'communication',
'severity': 'medium',
'priority': 'medium'
},
{
'title': 'Nurse was compassionate and helpful',
'description': 'I want to express my appreciation for nurse {staff_name} who went above and beyond to make me comfortable during my stay. Her kind and caring demeanor made a difficult situation much more bearable.',
'category': 'staff_behavior',
'severity': 'low',
'priority': 'low'
},
{
'title': 'Physician provided excellent care',
'description': 'Dr. {staff_name} provided exceptional care and took the time to thoroughly explain my condition and treatment options. His expertise and bedside manner were outstanding.',
'category': 'clinical_care',
'severity': 'low',
'priority': 'low'
}
],
'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 for an emergency situation.',
'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. This is concerning for patient safety.',
'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. The room was uncomfortably hot which affected my recovery.',
'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 but have not received any assistance.',
'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. This has caused delays in my treatment.',
'category': 'communication',
'severity': 'medium',
'priority': 'medium'
},
{
'title': 'Parking is inadequate for visitors',
'description': 'There is very limited parking available for visitors. I had to circle multiple times to find a spot and was late for my appointment. This needs to be addressed.',
'category': 'facility',
'severity': 'low',
'priority': 'low'
},
{
'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. This affects patient satisfaction.',
'category': 'facility',
'severity': 'medium',
'priority': 'medium'
}
]
}
# Arabic complaint templates
ARABIC_COMPLAINTS = {
'staff_mentioned': [
{
'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'
},
{
'title': 'الممرضة كانت متعاطفة ومساعدة',
'description': 'أريد أن أعبر عن تقديري للممرضة {staff_name} التي بذلت ما هو أبعد من المتوقع لجعلي مرتاحاً خلال إقامتي. كلمتها اللطيفة والراعية جعلت الموقف الصعب أكثر قابلية للتحمل.',
'category': 'staff_behavior',
'severity': 'low',
'priority': 'low'
},
{
'title': 'الطبيب قدم رعاية ممتازة',
'description': 'قدم د. {staff_name} رعاية استثنائية وأخذ الوقت لتوضيح حالتي وخيارات العلاج بدقة. كانت خبرته وأسلوبه مع المرضى ممتازين.',
'category': 'clinical_care',
'severity': 'low',
'priority': 'low'
}
],
'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': 'low',
'priority': 'low'
},
{
'title': 'جودة الطعام انخفضت',
'description': 'جودة طعام المستشفى انخفضت بشكل كبير. الوجبات غالباً باردة وغير شهية ولا تلبي المتطلبات الغذائية. هذا يؤثر على رضا المرضى.',
'category': 'facility',
'severity': 'medium',
'priority': 'medium'
}
]
}
# Patient names for complaints
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 = [
'محمد العتيبي', 'فاطمة الدوسري', 'أحمد القحطاني', 'سارة الشمري',
'خالد الحربي', 'نورة المطيري', 'عبدالله العنزي', 'مريم الزهراني',
'سعود الشهري', 'هند السالم'
]
# Source mapping for PXSource
SOURCE_MAPPING = {
'patient': ('Patient', 'مريض'),
'family': ('Family Member', 'عضو العائلة'),
'staff': ('Staff', 'موظف'),
'call_center': ('Call Center', 'مركز الاتصال'),
'online': ('Online Form', 'نموذج عبر الإنترنت'),
'in_person': ('In Person', 'شخصياً'),
'survey': ('Survey', 'استبيان'),
'social_media': ('Social Media', 'وسائل التواصل الاجتماعي'),
}
# Categories mapping
CATEGORY_MAP = {
'clinical_care': 'الرعاية السريرية',
'staff_behavior': 'سلوك الموظفين',
'facility': 'المرافق والبيئة',
'wait_time': 'وقت الانتظار',
'billing': 'الفواتير',
'communication': 'التواصل',
'other': 'أخرى'
}
class Command(BaseCommand):
help = 'Seed complaint data with bilingual support (English and Arabic)'
def add_arguments(self, parser):
parser.add_argument(
'--count',
type=int,
default=10,
help='Number of complaints to create (default: 10)'
)
parser.add_argument(
'--arabic-percent',
type=int,
default=70,
help='Percentage of Arabic complaints (default: 70)'
)
parser.add_argument(
'--hospital-code',
type=str,
help='Target hospital code (default: all hospitals)'
)
parser.add_argument(
'--staff-mention-percent',
type=int,
default=60,
help='Percentage of staff-mentioned complaints (default: 60)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview without making changes'
)
parser.add_argument(
'--clear',
action='store_true',
help='Clear existing complaints first'
)
def handle(self, *args, **options):
count = options['count']
arabic_percent = options['arabic_percent']
hospital_code = options['hospital_code']
staff_mention_percent = options['staff_mention_percent']
dry_run = options['dry_run']
clear_existing = options['clear']
self.stdout.write(f"\n{'='*60}")
self.stdout.write("Complaint Data Seeding Command")
self.stdout.write(f"{'='*60}\n")
with transaction.atomic():
# Get hospitals
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. Please create hospitals first.")
)
return
self.stdout.write(
self.style.SUCCESS(f"Found {hospitals.count()} hospital(s)")
)
# Get all categories
all_categories = ComplaintCategory.objects.filter(is_active=True)
if not all_categories.exists():
self.stdout.write(
self.style.ERROR("No complaint categories found. Please run seed_complaint_configs first.")
)
return
# Get all staff
all_staff = Staff.objects.filter(status='active')
if not all_staff.exists():
self.stdout.write(
self.style.WARNING("No staff found. Staff-mentioned complaints will not have linked staff.")
)
# Ensure PXSource instances exist
self.ensure_pxsources()
# Display configuration
self.stdout.write("\nConfiguration:")
self.stdout.write(f" Total complaints to create: {count}")
arabic_count = int(count * arabic_percent / 100)
english_count = count - arabic_count
self.stdout.write(f" Arabic complaints: {arabic_count} ({arabic_percent}%)")
self.stdout.write(f" English complaints: {english_count} ({100-arabic_percent}%)")
staff_mentioned_count = int(count * staff_mention_percent / 100)
general_count = count - staff_mentioned_count
self.stdout.write(f" Staff-mentioned: {staff_mentioned_count} ({staff_mention_percent}%)")
self.stdout.write(f" General: {general_count} ({100-staff_mention_percent}%)")
self.stdout.write(f" Status: All OPEN")
self.stdout.write(f" Dry run: {dry_run}")
# Clear existing complaints if requested
if clear_existing:
if dry_run:
self.stdout.write(
self.style.WARNING(f"\nWould delete {Complaint.objects.count()} existing complaints")
)
else:
deleted_count = Complaint.objects.count()
Complaint.objects.all().delete()
self.stdout.write(
self.style.SUCCESS(f"\n✓ Deleted {deleted_count} existing complaints")
)
# Track created complaints
created_complaints = []
by_language = {'en': 0, 'ar': 0}
by_type = {'staff_mentioned': 0, 'general': 0}
# Create complaints
for i in range(count):
# Determine language (alternate based on percentage)
is_arabic = i < arabic_count
lang = 'ar' if is_arabic else 'en'
# Determine type (staff-mentioned vs general)
is_staff_mentioned = random.random() < (staff_mention_percent / 100)
complaint_type = 'staff_mentioned' if is_staff_mentioned else 'general'
# Select hospital (round-robin through available hospitals)
hospital = hospitals[i % len(hospitals)]
# Select staff if needed
staff_member = None
if is_staff_mentioned and all_staff.exists():
# Try to find staff from same hospital
hospital_staff = all_staff.filter(hospital=hospital)
if hospital_staff.exists():
staff_member = random.choice(hospital_staff)
else:
staff_member = random.choice(all_staff)
# Get complaint templates for language and type
templates = ARABIC_COMPLAINTS[complaint_type] if is_arabic else ENGLISH_COMPLAINTS[complaint_type]
template = random.choice(templates)
# Get category
category_code = template['category']
category = all_categories.filter(code=category_code).first()
# Prepare complaint data
complaint_data = self.prepare_complaint_data(
template=template,
staff_member=staff_member,
category=category,
hospital=hospital,
is_arabic=is_arabic,
i=i
)
if dry_run:
self.stdout.write(
f" Would create: {complaint_data['title']} ({lang.upper()}) - {complaint_type}"
)
created_complaints.append({
'title': complaint_data['title'],
'language': lang,
'type': complaint_type
})
else:
# Create complaint
complaint = Complaint.objects.create(**complaint_data)
# Create timeline entry
self.create_timeline_entry(complaint)
created_complaints.append(complaint)
# Track statistics
by_language[lang] += 1
by_type[complaint_type] += 1
# Summary
self.stdout.write("\n" + "="*60)
self.stdout.write("Summary:")
self.stdout.write(f" Total complaints created: {len(created_complaints)}")
self.stdout.write(f" Arabic: {by_language['ar']}")
self.stdout.write(f" English: {by_language['en']}")
self.stdout.write(f" Staff-mentioned: {by_type['staff_mentioned']}")
self.stdout.write(f" General: {by_type['general']}")
self.stdout.write("="*60 + "\n")
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN: No changes were made\n"))
else:
self.stdout.write(self.style.SUCCESS("Complaint seeding completed successfully!\n"))
def prepare_complaint_data(self, template, staff_member, category, hospital, is_arabic, i):
"""Prepare complaint data from template"""
# Generate description with staff name if applicable
description = template['description']
if staff_member:
staff_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=staff_name, date=timezone.now().date())
# Generate reference number
reference = self.generate_reference_number(hospital.code)
# Generate patient name
patient_names = PATIENT_NAMES_AR if is_arabic else PATIENT_NAMES_EN
patient_name = patient_names[i % len(patient_names)]
# Generate contact info
contact_method = random.choice(['email', 'phone', 'both'])
if contact_method == 'email':
email = f"patient{i}@example.com"
phone = ""
elif contact_method == 'phone':
email = ""
phone = f"+9665{random.randint(10000000, 99999999)}"
else:
email = f"patient{i}@example.com"
phone = f"+9665{random.randint(10000000, 99999999)}"
# Select source key
source_key = random.choice(list(SOURCE_MAPPING.keys()))
source_instance = self.get_source_instance(source_key)
# Get department (if staff member exists, use their department)
department = staff_member.department if staff_member else None
# Prepare complaint data
data = {
'reference_number': reference,
'hospital': hospital,
'department': department,
'category': category,
'title': template['title'],
'description': description,
'severity': template['severity'],
'priority': template['priority'],
'source': source_instance,
'status': 'open',
'contact_name': patient_name,
'contact_phone': phone,
'contact_email': email,
'staff': staff_member,
}
return data
def generate_reference_number(self, hospital_code):
"""Generate unique complaint reference number"""
short_uuid = str(uuid.uuid4())[:8].upper()
year = timezone.now().year
return f"CMP-{hospital_code}-{year}-{short_uuid}"
def create_timeline_entry(self, complaint):
"""Create initial timeline entry for complaint"""
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='status_change',
old_status='',
new_status='open',
message='Complaint created and registered',
created_by=None # System-created
)
def ensure_pxsources(self):
"""Ensure all required PXSource instances exist"""
for source_key, (name_en, name_ar) in SOURCE_MAPPING.items():
PXSource.objects.get_or_create(
name_en=name_en,
defaults={
'name_ar': name_ar,
'description': f'{name_en} source for complaints and inquiries',
'is_active': True
}
)
def get_source_instance(self, source_key):
"""Get PXSource instance by source key"""
name_en, _ = SOURCE_MAPPING.get(source_key, ('Other', 'أخرى'))
try:
return PXSource.objects.get(name_en=name_en, is_active=True)
except PXSource.DoesNotExist:
# Fallback to first active source
return PXSource.objects.filter(is_active=True).first()

View File

@ -0,0 +1,128 @@
"""
Management command to sync complaint_type field from AI metadata.
This command updates the complaint_type model field for complaints
that have AI analysis stored in metadata but the model field
hasn't been updated yet.
Usage:
python manage.py sync_complaint_types [--dry-run] [--hospital-id HOSPITAL_ID]
"""
from django.core.management.base import BaseCommand
from django.db.models import Q
class Command(BaseCommand):
help = 'Sync complaint_type field from AI metadata'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
dest='dry_run',
help='Show what would be updated without making changes',
)
parser.add_argument(
'--hospital-id',
type=str,
dest='hospital_id',
help='Only sync complaints for a specific hospital',
)
def handle(self, *args, **options):
from apps.complaints.models import Complaint
dry_run = options.get('dry_run', False)
hospital_id = options.get('hospital_id')
self.stdout.write(self.style.WARNING('Starting complaint_type sync...'))
# Build query for complaints that need syncing
queryset = Complaint.objects.filter(
Q(metadata__ai_analysis__complaint_type__isnull=False) &
(
Q(complaint_type='complaint') | # Default value
Q(complaint_type__isnull=False)
)
)
# Filter by hospital if specified
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
self.stdout.write(f"Filtering by hospital_id: {hospital_id}")
# Count total
total = queryset.count()
self.stdout.write(f"Found {total} complaints to check")
if total == 0:
self.stdout.write(self.style.SUCCESS('No complaints need syncing'))
return
# Process complaints
updated = 0
skipped = 0
errors = 0
for complaint in queryset:
try:
ai_type = complaint.metadata.get('ai_analysis', {}).get('complaint_type', 'complaint')
# Check if model field differs from metadata
if complaint.complaint_type != ai_type:
if dry_run:
self.stdout.write(
f"Would update complaint {complaint.id}: "
f"'{complaint.complaint_type}' -> '{ai_type}'"
)
else:
# Update the complaint_type field
complaint.complaint_type = ai_type
complaint.save(update_fields=['complaint_type'])
self.stdout.write(
f"Updated complaint {complaint.id}: "
f"'{complaint.complaint_type}' -> '{ai_type}'"
)
updated += 1
else:
skipped += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Error processing complaint {complaint.id}: {str(e)}")
)
errors += 1
# Summary
self.stdout.write('\n' + '=' * 60)
self.stdout.write(self.style.SUCCESS('Sync Complete'))
self.stdout.write('=' * 60)
self.stdout.write(f"Total complaints checked: {total}")
self.stdout.write(f"Updated: {updated}")
self.stdout.write(f"Skipped (already in sync): {skipped}")
self.stdout.write(f"Errors: {errors}")
if dry_run:
self.stdout.write('\n' + self.style.WARNING('DRY RUN - No changes were made'))
else:
self.stdout.write(f"\n{self.style.SUCCESS(f'Successfully updated {updated} complaint(s)')}")
# Show breakdown by type
if updated > 0 and not dry_run:
self.stdout.write('\n' + '=' * 60)
self.stdout.write('Updated Complaints by Type:')
self.stdout.write('=' * 60)
type_counts = {}
queryset = Complaint.objects.filter(
Q(metadata__ai_analysis__complaint_type__isnull=False) &
Q(hospital_id=hospital_id) if hospital_id else Q()
)
for complaint in queryset:
ai_type = complaint.metadata.get('ai_analysis', {}).get('complaint_type', 'complaint')
if complaint.complaint_type == ai_type:
type_counts[ai_type] = type_counts.get(ai_type, 0) + 1
for complaint_type, count in sorted(type_counts.items()):
self.stdout.write(f" {complaint_type}: {count}")

View File

@ -0,0 +1,410 @@
"""
Management command to test staff matching functionality in complaints.
This command creates a test complaint with 2-3 staff members mentioned
and verifies if the AI-based staff matching is working correctly.
"""
import random
import uuid
from datetime import datetime
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, ComplaintCategory, ComplaintUpdate
from apps.organizations.models import Hospital, Department, Staff
from apps.px_sources.models import PXSource
from apps.core.ai_service import AIService
# English complaint templates with placeholders for staff names
ENGLISH_COMPLAINT_TEMPLATES = [
{
'title': 'Issues with multiple staff members',
'description': 'I had a very unpleasant experience during my stay. Nurse {staff1_name} was rude and dismissive when I asked for pain medication. Later, Dr. {staff2_name} did not explain my treatment plan properly and seemed rushed. The third staff member, {staff3_name}, was actually helpful but the overall experience was poor.',
'category': 'staff_behavior',
'severity': 'high',
'priority': 'high'
},
{
'title': 'Excellent care from nursing team',
'description': 'I want to commend the excellent care I received. Nurse {staff1_name} was particularly attentive and caring throughout my stay. {staff2_name} also went above and beyond to ensure my comfort. Dr. {staff3_name} was thorough and took time to answer all my questions.',
'category': 'clinical_care',
'severity': 'low',
'priority': 'low'
},
{
'title': 'Mixed experience with hospital staff',
'description': 'My experience was mixed. Nurse {staff1_name} was professional and efficient, but {staff2_name} made a medication error that was concerning. Dr. {staff3_name} was helpful in resolving the situation, but the initial error was unacceptable.',
'category': 'clinical_care',
'severity': 'high',
'priority': 'high'
}
]
# Arabic complaint templates with placeholders for staff names
ARABIC_COMPLAINT_TEMPLATES = [
{
'title': 'مشاكل مع عدة موظفين',
'description': 'كانت لدي تجربة غير سارة جداً خلال إقامتي. الممرضة {staff1_name} كانت غير مهذبة ومتجاهلة عندما طلبت دواء للم. لاحقاً، د. {staff2_name} لم يوضح خطة علاجي بشكل صحيح وبدو متسرع. كان الموظف الثالث {staff3_name} مفيداً فعلاً ولكن التجربة العامة كانت سيئة.',
'category': 'staff_behavior',
'severity': 'high',
'priority': 'high'
},
{
'title': 'رعاية ممتازة من فريق التمريض',
'description': 'أريد أن أشكر الرعاية الممتازة التي تلقيتها. الممرضة {staff1_name} كانت مهتمة وراعية بشكل خاص طوال إقامتي. {staff2_name} أيضاً بذل ما هو أبعد من المتوقع لضمان راحتي. د. {staff3_name} كان دقيقاً وأخذ وقتاً للإجابة على جميع أسئلتي.',
'category': 'clinical_care',
'severity': 'low',
'priority': 'low'
},
{
'title': 'تجربة مختلطة مع موظفي المستشفى',
'description': 'كانت تجربتي مختلطة. الممرضة {staff1_name} كانت مهنية وفعالة، لكن {staff2_name} ارتكب خطأ في الدواء كان مقلقاً. د. {staff3_name} كان مفيداً في حل الموقف، لكن الخطأ الأولي كان غير مقبول.',
'category': 'clinical_care',
'severity': 'high',
'priority': 'high'
}
]
class Command(BaseCommand):
help = 'Test staff matching functionality by creating a complaint with mentioned staff'
def add_arguments(self, parser):
parser.add_argument(
'--hospital-code',
type=str,
help='Target hospital code (default: first active hospital)'
)
parser.add_argument(
'--staff-count',
type=int,
default=3,
help='Number of staff to test (2 or 3, default: 3)'
)
parser.add_argument(
'--language',
type=str,
default='en',
choices=['en', 'ar'],
help='Complaint language (en/ar, default: en)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview without creating complaint'
)
parser.add_argument(
'--template-index',
type=int,
help='Template index to use (0-2, default: random)'
)
def handle(self, *args, **options):
hospital_code = options['hospital_code']
staff_count = options['staff_count']
language = options['language']
dry_run = options['dry_run']
template_index = options['template_index']
# Validate staff count
if staff_count not in [2, 3]:
self.stdout.write(self.style.ERROR("staff-count must be 2 or 3"))
return
self.stdout.write(f"\n{'='*80}")
self.stdout.write("🧪 STAFF MATCHING TEST COMMAND")
self.stdout.write(f"{'='*80}\n")
# Get hospital
if hospital_code:
hospital = Hospital.objects.filter(code=hospital_code).first()
if not hospital:
self.stdout.write(
self.style.ERROR(f"Hospital with code '{hospital_code}' not found")
)
return
else:
hospital = Hospital.objects.filter(status='active').first()
if not hospital:
self.stdout.write(
self.style.ERROR("No active hospitals found")
)
return
self.stdout.write(f"🏥 Hospital: {hospital.name} (Code: {hospital.code})")
# Get active staff from hospital
all_staff = Staff.objects.filter(hospital=hospital, status='active')
if all_staff.count() < staff_count:
self.stdout.write(
self.style.ERROR(
f"Not enough staff found. Found {all_staff.count()}, need {staff_count}"
)
)
return
# Select random staff
selected_staff = random.sample(list(all_staff), staff_count)
self.stdout.write(f"\n👥 Selected Staff ({staff_count} members):")
for i, staff in enumerate(selected_staff, 1):
if language == 'ar' and staff.first_name_ar:
name = f"{staff.first_name_ar} {staff.last_name_ar}"
name_en = f"{staff.first_name} {staff.last_name}"
else:
name = f"{staff.first_name} {staff.last_name}"
name_en = name
self.stdout.write(
f" {i}. {name} (EN: {name_en})"
)
self.stdout.write(f" ID: {staff.id}")
self.stdout.write(f" Job Title: {staff.job_title}")
self.stdout.write(f" Department: {staff.department.name if staff.department else 'N/A'}")
# Select template
templates = ARABIC_COMPLAINT_TEMPLATES if language == 'ar' else ENGLISH_COMPLAINT_TEMPLATES
if template_index is not None:
if 0 <= template_index < len(templates):
template = templates[template_index]
else:
self.stdout.write(
self.style.WARNING(f"Template index {template_index} out of range, using random")
)
template = random.choice(templates)
else:
template = random.choice(templates)
# Prepare complaint data
complaint_data = self.prepare_complaint(
template=template,
staff=selected_staff,
hospital=hospital,
language=language
)
self.stdout.write(f"\n📋 Complaint Details:")
self.stdout.write(f" Title: {complaint_data['title']}")
self.stdout.write(f" Category: {complaint_data['category']}")
self.stdout.write(f" Severity: {complaint_data['severity']}")
self.stdout.write(f" Priority: {complaint_data['priority']}")
self.stdout.write(f"\n Description:")
self.stdout.write(f" {complaint_data['description']}")
# Test staff matching
self.stdout.write(f"\n{'='*80}")
self.stdout.write("🔍 STAFF MATCHING TEST")
self.stdout.write(f"{'='*80}\n")
from apps.complaints.tasks import match_staff_from_name
matched_staff = []
unmatched_staff = []
for staff in selected_staff:
if language == 'ar' and staff.first_name_ar:
name_to_match = f"{staff.first_name_ar} {staff.last_name_ar}"
else:
name_to_match = f"{staff.first_name} {staff.last_name}"
self.stdout.write(f"\n🔎 Testing: '{name_to_match}'")
self.stdout.write(f" Staff ID: {staff.id}")
# Test matching
matches, confidence, method = match_staff_from_name(
staff_name=name_to_match,
hospital_id=str(hospital.id),
department_name=None,
return_all=True
)
if matches:
found = any(m['id'] == str(staff.id) for m in matches)
if found:
self.stdout.write(
self.style.SUCCESS(f" ✅ MATCHED! (confidence: {confidence:.2f}, method: {method})")
)
matched_staff.append({
'staff': staff,
'confidence': confidence,
'method': method
})
else:
self.stdout.write(
self.style.WARNING(f" ⚠️ Found {len(matches)} matches but not the correct one")
)
for i, match in enumerate(matches[:3], 1):
self.stdout.write(f" {i}. {match['name_en']} (confidence: {match['confidence']:.2f})")
unmatched_staff.append(staff)
else:
self.stdout.write(
self.style.ERROR(f" ❌ NO MATCHES (confidence: {confidence:.2f}, method: {method})")
)
unmatched_staff.append(staff)
# Summary
self.stdout.write(f"\n{'='*80}")
self.stdout.write("📊 TEST SUMMARY")
self.stdout.write(f"{'='*80}\n")
self.stdout.write(f"Total staff tested: {len(selected_staff)}")
self.stdout.write(f"Matched: {len(matched_staff)}")
self.stdout.write(f"Unmatched: {len(unmatched_staff)}")
if matched_staff:
self.stdout.write(f"\n✅ Matched Staff:")
for item in matched_staff:
staff = item['staff']
name = f"{staff.first_name} {staff.last_name}"
self.stdout.write(f" - {name} (confidence: {item['confidence']:.2f}, method: {item['method']})")
if unmatched_staff:
self.stdout.write(f"\n❌ Unmatched Staff:")
for staff in unmatched_staff:
name = f"{staff.first_name} {staff.last_name}"
self.stdout.write(f" - {name} (ID: {staff.id})")
# Create complaint if not dry run
if not dry_run:
self.stdout.write(f"\n{'='*80}")
self.stdout.write("💾 CREATING COMPLAINT")
self.stdout.write(f"{'='*80}\n")
try:
with transaction.atomic():
# Create complaint
complaint = Complaint.objects.create(
reference_number=self.generate_reference_number(hospital.code),
hospital=hospital,
department=selected_staff[0].department if selected_staff[0].department else None,
category=complaint_data['category'],
title=complaint_data['title'],
description=complaint_data['description'],
severity=complaint_data['severity'],
priority=complaint_data['priority'],
source=self.get_source_instance(),
status='open',
contact_name='Test Patient',
contact_phone='+966500000000',
contact_email='test@example.com',
)
# Create timeline entry
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='status_change',
old_status='',
new_status='open',
message='Complaint created for staff matching test',
created_by=None
)
self.stdout.write(
self.style.SUCCESS(f"✓ Complaint created successfully!")
)
self.stdout.write(f" Reference: {complaint.reference_number}")
self.stdout.write(f" ID: {complaint.id}")
# Trigger AI analysis
self.stdout.write(f"\n{'='*80}")
self.stdout.write("🤖 AI ANALYSIS")
self.stdout.write(f"{'='*80}\n")
ai_service = AIService()
analysis = ai_service.analyze_complaint(
title=complaint.title,
description=complaint.description,
category=complaint.category.name_en if complaint.category else None,
hospital_id=hospital.id
)
self.stdout.write(f"AI Analysis Results:")
# Display extracted staff names
staff_names = analysis.get('staff_names', [])
if staff_names:
self.stdout.write(f"\n Extracted Staff Names ({len(staff_names)}):")
for i, staff_name in enumerate(staff_names, 1):
self.stdout.write(f" {i}. {staff_name}")
else:
self.stdout.write(f" No staff names extracted")
# Display primary staff
primary_staff = analysis.get('primary_staff_name', '')
if primary_staff:
self.stdout.write(f"\n Primary Staff: {primary_staff}")
# Display classification results
self.stdout.write(f"\n Classification:")
self.stdout.write(f" - Complaint Type: {analysis.get('complaint_type', 'N/A')}")
self.stdout.write(f" - Severity: {analysis.get('severity', 'N/A')}")
self.stdout.write(f" - Priority: {analysis.get('priority', 'N/A')}")
self.stdout.write(f" - Category: {analysis.get('category', 'N/A')}")
self.stdout.write(f" - Subcategory: {analysis.get('subcategory', 'N/A')}")
self.stdout.write(f" - Department: {analysis.get('department', 'N/A')}")
self.stdout.write(f"\n{'='*80}")
self.stdout.write(f"✅ TEST COMPLETED")
self.stdout.write(f"{'='*80}\n")
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Error creating complaint: {str(e)}")
)
import traceback
self.stdout.write(traceback.format_exc())
else:
self.stdout.write(f"\n{'='*80}")
self.stdout.write(self.style.WARNING("🔍 DRY RUN - No changes made"))
self.stdout.write(f"{'='*80}\n")
def prepare_complaint(self, template, staff, hospital, language):
"""Prepare complaint data from template with staff names"""
# Get category
category = ComplaintCategory.objects.filter(
code=template['category'],
is_active=True
).first()
# Format description with staff names
description = template['description']
if len(staff) == 2:
description = description.format(
staff1_name=self.get_staff_name(staff[0], language),
staff2_name=self.get_staff_name(staff[1], language),
staff3_name=''
)
elif len(staff) == 3:
description = description.format(
staff1_name=self.get_staff_name(staff[0], language),
staff2_name=self.get_staff_name(staff[1], language),
staff3_name=self.get_staff_name(staff[2], language)
)
return {
'title': template['title'],
'description': description,
'category': category,
'severity': template['severity'],
'priority': template['priority']
}
def get_staff_name(self, staff, language):
"""Get staff name in appropriate language"""
if language == 'ar' and staff.first_name_ar:
return f"{staff.first_name_ar} {staff.last_name_ar}"
else:
return f"{staff.first_name} {staff.last_name}"
def generate_reference_number(self, hospital_code):
"""Generate unique complaint reference number"""
short_uuid = str(uuid.uuid4())[:8].upper()
year = timezone.now().year
return f"CMP-{hospital_code}-{year}-{short_uuid}"
def get_source_instance(self):
"""Get PXSource instance"""
try:
return PXSource.objects.get(name_en='Online Form', is_active=True)
except PXSource.DoesNotExist:
return PXSource.objects.filter(is_active=True).first()

View File

@ -1,250 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ComplaintAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='complaints/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ComplaintCategory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(help_text='Unique code for this category', max_length=50)),
('name_en', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('description_en', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True)),
('order', models.IntegerField(default=0, help_text='Display order')),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name_plural': 'Complaint Categories',
'ordering': ['order', 'name_en'],
},
),
migrations.CreateModel(
name='ComplaintExplanation',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('explanation', models.TextField(help_text="Staff's explanation about the complaint")),
('token', models.CharField(db_index=True, help_text='Unique access token for explanation submission', max_length=64, unique=True)),
('is_used', models.BooleanField(db_index=True, default=False, help_text='Token expiry tracking - becomes True after submission')),
('submitted_via', models.CharField(choices=[('email_link', 'Email Link'), ('direct', 'Direct Entry')], default='email_link', help_text='How the explanation was submitted', max_length=20)),
('email_sent_at', models.DateTimeField(blank=True, help_text='When the explanation request email was sent', null=True)),
('responded_at', models.DateTimeField(blank=True, help_text='When the explanation was submitted', null=True)),
('request_message', models.TextField(blank=True, help_text='Optional message sent with the explanation request')),
],
options={
'verbose_name': 'Complaint Explanation',
'verbose_name_plural': 'Complaint Explanations',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ComplaintSLAConfig',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Severity level for this SLA', max_length=20)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Priority level for this SLA', max_length=20)),
('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')),
('reminder_hours_before', models.IntegerField(default=24, help_text='Send reminder X hours before deadline')),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['hospital', 'severity', 'priority'],
},
),
migrations.CreateModel(
name='ComplaintThreshold',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('threshold_type', models.CharField(choices=[('resolution_survey_score', 'Resolution Survey Score'), ('response_time', 'Response Time'), ('resolution_time', 'Resolution Time')], help_text='Type of threshold', max_length=50)),
('threshold_value', models.FloatField(help_text='Threshold value (e.g., 50 for 50% score)')),
('comparison_operator', models.CharField(choices=[('lt', 'Less Than'), ('lte', 'Less Than or Equal'), ('gt', 'Greater Than'), ('gte', 'Greater Than or Equal'), ('eq', 'Equal')], default='lt', help_text='How to compare against threshold', max_length=10)),
('action_type', models.CharField(choices=[('create_px_action', 'Create PX Action'), ('send_notification', 'Send Notification'), ('escalate', 'Escalate')], help_text='Action to take when threshold is breached', max_length=50)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['hospital', 'threshold_type'],
},
),
migrations.CreateModel(
name='ComplaintUpdate',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('update_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Note'), ('resolution', 'Resolution'), ('escalation', 'Escalation'), ('communication', 'Communication')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('metadata', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='EscalationRule',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('trigger_on_overdue', models.BooleanField(default=True, help_text='Trigger when complaint is overdue')),
('trigger_hours_overdue', models.IntegerField(default=0, help_text='Trigger X hours after overdue (0 = immediately)')),
('escalate_to_role', models.CharField(choices=[('department_manager', 'Department Manager'), ('hospital_admin', 'Hospital Admin'), ('px_admin', 'PX Admin'), ('specific_user', 'Specific User')], help_text='Role to escalate to', max_length=50)),
('severity_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this severity (blank = all)', max_length=20)),
('priority_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this priority (blank = all)', max_length=20)),
('order', models.IntegerField(default=0, help_text='Escalation order (lower = first)')),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['hospital', 'order'],
},
),
migrations.CreateModel(
name='ExplanationAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='explanation_attachments/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
],
options={
'verbose_name': 'Explanation Attachment',
'verbose_name_plural': 'Explanation Attachments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Inquiry',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('contact_name', models.CharField(blank=True, max_length=200)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('contact_email', models.EmailField(blank=True, max_length=254)),
('subject', models.CharField(max_length=500)),
('message', models.TextField()),
('category', models.CharField(choices=[('appointment', 'Appointment'), ('billing', 'Billing'), ('medical_records', 'Medical Records'), ('general', 'General Information'), ('other', 'Other')], max_length=100)),
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='open', max_length=20)),
('response', models.TextField(blank=True)),
('responded_at', models.DateTimeField(blank=True, null=True)),
],
options={
'verbose_name_plural': 'Inquiries',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='InquiryAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='inquiries/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='InquiryUpdate',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('update_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Note'), ('response', 'Response'), ('communication', 'Communication')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('metadata', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Complaint',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('contact_name', models.CharField(blank=True, max_length=200)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('contact_email', models.EmailField(blank=True, max_length=254)),
('reference_number', models.CharField(blank=True, db_index=True, help_text='Unique reference number for patient tracking', max_length=50, null=True, unique=True)),
('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)),
('title', models.CharField(max_length=500)),
('description', models.TextField()),
('subcategory', models.CharField(blank=True, max_length=100)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('cancelled', 'Cancelled')], db_index=True, default='open', max_length=20)),
('assigned_at', models.DateTimeField(blank=True, null=True)),
('due_at', models.DateTimeField(db_index=True, help_text='SLA deadline')),
('is_overdue', models.BooleanField(db_index=True, default=False)),
('reminder_sent_at', models.DateTimeField(blank=True, null=True)),
('escalated_at', models.DateTimeField(blank=True, null=True)),
('resolution', models.TextField(blank=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('resolution_survey_sent_at', models.DateTimeField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_complaints', to=settings.AUTH_USER_MODEL)),
('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_complaints', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.hospital')),
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.patient')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('complaints', '0001_initial'),
('surveys', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='complaint',
name='resolution_survey',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_resolution', to='surveys.surveyinstance'),
),
migrations.AddField(
model_name='complaint',
name='resolved_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_complaints', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,219 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('complaints', '0002_initial'),
('organizations', '0001_initial'),
('px_sources', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='complaint',
name='source',
field=models.ForeignKey(blank=True, help_text='Source of the complaint', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='px_sources.pxsource'),
),
migrations.AddField(
model_name='complaint',
name='staff',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.staff'),
),
migrations.AddField(
model_name='complaintattachment',
name='complaint',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaint'),
),
migrations.AddField(
model_name='complaintattachment',
name='uploaded_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_attachments', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='complaintcategory',
name='hospitals',
field=models.ManyToManyField(blank=True, help_text='Empty list = system-wide category. Add hospitals to share category.', related_name='complaint_categories', to='organizations.hospital'),
),
migrations.AddField(
model_name='complaintcategory',
name='parent',
field=models.ForeignKey(blank=True, help_text='Parent category for hierarchical structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='complaints.complaintcategory'),
),
migrations.AddField(
model_name='complaint',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='complaints.complaintcategory'),
),
migrations.AddField(
model_name='complaintexplanation',
name='complaint',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explanations', to='complaints.complaint'),
),
migrations.AddField(
model_name='complaintexplanation',
name='requested_by',
field=models.ForeignKey(blank=True, help_text='User who requested the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_complaint_explanations', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='complaintexplanation',
name='staff',
field=models.ForeignKey(blank=True, help_text='Staff member who submitted the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_explanations', to='organizations.staff'),
),
migrations.AddField(
model_name='complaintslaconfig',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_sla_configs', to='organizations.hospital'),
),
migrations.AddField(
model_name='complaintthreshold',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_thresholds', to='organizations.hospital'),
),
migrations.AddField(
model_name='complaintupdate',
name='complaint',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='complaints.complaint'),
),
migrations.AddField(
model_name='complaintupdate',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_updates', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='escalationrule',
name='escalate_to_user',
field=models.ForeignKey(blank=True, help_text="Specific user if escalate_to_role is 'specific_user'", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalation_target_rules', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='escalationrule',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='escalation_rules', to='organizations.hospital'),
),
migrations.AddField(
model_name='explanationattachment',
name='explanation',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaintexplanation'),
),
migrations.AddField(
model_name='inquiry',
name='assigned_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_inquiries', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='inquiry',
name='department',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.department'),
),
migrations.AddField(
model_name='inquiry',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.hospital'),
),
migrations.AddField(
model_name='inquiry',
name='patient',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.patient'),
),
migrations.AddField(
model_name='inquiry',
name='responded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responded_inquiries', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='inquiry',
name='source',
field=models.ForeignKey(blank=True, help_text='Source of inquiry', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inquiries', to='px_sources.pxsource'),
),
migrations.AddField(
model_name='inquiryattachment',
name='inquiry',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.inquiry'),
),
migrations.AddField(
model_name='inquiryattachment',
name='uploaded_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_attachments', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='inquiryupdate',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_updates', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='inquiryupdate',
name='inquiry',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='complaints.inquiry'),
),
migrations.AddIndex(
model_name='complaintcategory',
index=models.Index(fields=['code'], name='complaints__code_8e9bbe_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['status', '-created_at'], name='complaints__status_f077e8_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='complaints__hospita_cf53df_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['is_overdue', 'status'], name='complaints__is_over_3d3554_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['due_at', 'status'], name='complaints__due_at_836821_idx'),
),
migrations.AddIndex(
model_name='complaintexplanation',
index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_b20e58_idx'),
),
migrations.AddIndex(
model_name='complaintexplanation',
index=models.Index(fields=['token', 'is_used'], name='complaints__token_f8f9b7_idx'),
),
migrations.AddIndex(
model_name='complaintslaconfig',
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_bdf8a5_idx'),
),
migrations.AlterUniqueTogether(
name='complaintslaconfig',
unique_together={('hospital', 'severity', 'priority')},
),
migrations.AddIndex(
model_name='complaintthreshold',
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_b8efc9_idx'),
),
migrations.AddIndex(
model_name='complaintthreshold',
index=models.Index(fields=['threshold_type', 'is_active'], name='complaints__thresho_719969_idx'),
),
migrations.AddIndex(
model_name='complaintupdate',
index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_f3684e_idx'),
),
migrations.AddIndex(
model_name='escalationrule',
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_3c8bac_idx'),
),
migrations.AddIndex(
model_name='inquiry',
index=models.Index(fields=['status', '-created_at'], name='complaints__status_3d0678_idx'),
),
migrations.AddIndex(
model_name='inquiry',
index=models.Index(fields=['hospital', 'status'], name='complaints__hospita_b1573b_idx'),
),
migrations.AddIndex(
model_name='inquiryupdate',
index=models.Index(fields=['inquiry', '-created_at'], name='complaints__inquiry_551c37_idx'),
),
]

View File

@ -1,32 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 11:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0003_initial'),
('px_sources', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='complaint',
name='created_by',
field=models.ForeignKey(blank=True, help_text='User who created this complaint (SourceUser or Patient)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complaints', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='inquiry',
name='created_by',
field=models.ForeignKey(blank=True, help_text='User who created this inquiry (SourceUser or Patient)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_inquiries', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='complaint',
name='source',
field=models.ForeignKey(blank=True, help_text='Source of complaint', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='px_sources.pxsource'),
),
]

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ Complaints serializers
"""
from rest_framework import serializers
from .models import Complaint, ComplaintAttachment, ComplaintUpdate, Inquiry
from .models import Complaint, ComplaintAttachment, ComplaintUpdate, Inquiry,ComplaintExplanation
class ComplaintAttachmentSerializer(serializers.ModelSerializer):
@ -164,44 +164,81 @@ class ComplaintSerializer(serializers.ModelSerializer):
def get_sla_status(self, obj):
"""Get SLA status"""
if obj.is_overdue:
return 'overdue'
return obj.sla_status if hasattr(obj, 'sla_status') else 'on_track'
from django.utils import timezone
time_remaining = obj.due_at - timezone.now()
hours_remaining = time_remaining.total_seconds() / 3600
if hours_remaining < 4:
return 'due_soon'
return 'on_time'
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'
]
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)
staff_name = serializers.SerializerMethodField()
assigned_to_name = serializers.SerializerMethodField()
source_name = serializers.CharField(source='source.name_en', read_only=True)
sla_status = serializers.SerializerMethodField()
class Meta:
model = Complaint
fields = [
'id', 'title', 'patient_name', 'hospital_name',
'category', 'severity', 'status', 'sla_status',
'assigned_to', 'created_at'
'id', 'patient_name', 'patient_mrn', 'encounter_id',
'hospital_name', 'department_name', 'staff_name',
'title', 'category', 'subcategory',
'priority', 'severity', '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):
"""Get staff name"""
if obj.staff:
return f"{obj.staff.first_name} {obj.staff.last_name}"
return None
def get_assigned_to_name(self, obj):
"""Get assigned user name"""
if obj.assigned_to:
return obj.assigned_to.get_full_name()
return None
def get_sla_status(self, obj):
"""Get SLA status"""
if obj.is_overdue:
return '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 'due_soon'
return 'on_time'
return obj.sla_status if hasattr(obj, 'sla_status') else 'on_track'
class InquirySerializer(serializers.ModelSerializer):

View File

@ -46,7 +46,7 @@ def handle_complaint_created(sender, instance, created, **kwargs):
event_type='created'
)
logger.info(f"Complaint created: {instance.id} - {instance.title} - Async tasks queued")
logger.info(f"Complaint created: {instance.id} - {instance.title} - Async tasks queued")
except Exception as e:
# Log the error but don't prevent complaint creation
logger.warning(

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,370 @@
"""
Enhanced staff matching with fuzzy matching and improved accuracy.
This module provides improved staff matching functions with:
- Fuzzy string matching (Levenshtein distance)
- Better handling of name variations
- Matching against original full name field
- Improved confidence scoring
"""
import logging
from typing import Optional, Dict, Any, Tuple, List
from django.db.models import Q
logger = logging.getLogger(__name__)
def fuzzy_match_ratio(str1: str, str2: str) -> float:
"""
Calculate fuzzy match ratio using difflib.
Args:
str1: First string
str2: Second string
Returns:
Float from 0.0 to 1.0 representing similarity
"""
try:
from difflib import SequenceMatcher
return SequenceMatcher(None, str1.lower(), str2.lower()).ratio()
except Exception:
return 0.0
def normalize_name(name: str) -> str:
"""
Normalize name for better matching.
- Remove extra spaces
- Remove hyphens (Al-Shammari -> AlShammari)
- Convert to lowercase
- Remove common titles
"""
if not name:
return ""
name = name.strip().lower()
# Remove common titles (both English and Arabic)
titles = ['dr.', 'dr', 'mr.', 'mr', 'mrs.', 'mrs', 'ms.', 'ms',
'د.', 'السيد', 'السيدة', 'الدكتور']
for title in titles:
if name.startswith(title):
name = name[len(title):].strip()
# Remove hyphens for better matching (Al-Shammari -> AlShammari)
name = name.replace('-', '')
# Remove extra spaces
while ' ' in name:
name = name.replace(' ', ' ')
return name.strip()
def match_staff_from_name_enhanced(
staff_name: str,
hospital_id: str,
department_name: Optional[str] = None,
return_all: bool = False,
fuzzy_threshold: float = 0.65
) -> Tuple[list, float, str]:
"""
Enhanced staff matching with fuzzy matching and better accuracy.
Args:
staff_name: Name extracted from complaint (without titles)
hospital_id: Hospital ID to search within
department_name: Optional department name to prioritize matching
return_all: If True, return all matching staff. If False, return single best match.
fuzzy_threshold: Minimum similarity ratio for fuzzy matches (0.0 to 1.0)
Returns:
If return_all=True: Tuple of (matches_list, confidence_score, matching_method)
If return_all=False: Tuple of (staff_id, confidence_score, matching_method)
"""
from apps.organizations.models import Staff, Department
if not staff_name or not staff_name.strip():
return [], 0.0, "No staff name provided"
staff_name = staff_name.strip()
normalized_input = normalize_name(staff_name)
matches = []
# Build base query - staff from this hospital, active status
base_query = Staff.objects.filter(
hospital_id=hospital_id,
status='active'
)
# Get department if specified
dept_id = None
if department_name:
department = Department.objects.filter(
hospital_id=hospital_id,
name__iexact=department_name,
status='active'
).first()
if department:
dept_id = department.id
# Fetch all staff to perform fuzzy matching
all_staff = list(base_query)
# If department specified, filter
if dept_id:
dept_staff = [s for s in all_staff if str(s.department.id) == dept_id if s.department]
else:
dept_staff = []
# ========================================
# LAYER 1: EXACT MATCHES
# ========================================
# 1a. Exact match on first_name + last_name (English)
words = staff_name.split()
if len(words) >= 2:
first_name = words[0]
last_name = ' '.join(words[1:])
for staff in all_staff:
if staff.first_name.lower() == first_name.lower() and \
staff.last_name.lower() == last_name.lower():
confidence = 0.95 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.90
method = f"Exact English match in {'correct' if (dept_id and staff.department and str(staff.department.id) == dept_id) else 'any'} department"
if not any(m['id'] == str(staff.id) for m in matches):
matches.append(create_match_dict(staff, confidence, method, staff_name))
logger.info(f"EXACT MATCH (EN): {staff.first_name} {staff.last_name} == {first_name} {last_name}")
# 1b. Exact match on full Arabic name
for staff in all_staff:
full_arabic = f"{staff.first_name_ar} {staff.last_name_ar}".strip()
if full_arabic == staff_name:
confidence = 0.95 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.90
method = f"Exact Arabic match in {'correct' if (dept_id and staff.department and str(staff.department.id) == dept_id) else 'any'} department"
if not any(m['id'] == str(staff.id) for m in matches):
matches.append(create_match_dict(staff, confidence, method, staff_name))
logger.info(f"EXACT MATCH (AR): {full_arabic} == {staff_name}")
# 1c. Exact match on 'name' field (original full name)
for staff in all_staff:
if staff.name and staff.name.lower() == staff_name.lower():
confidence = 0.93
method = "Exact match on original name field"
if not any(m['id'] == str(staff.id) for m in matches):
matches.append(create_match_dict(staff, confidence, method, staff_name))
logger.info(f"EXACT MATCH (name field): {staff.name} == {staff_name}")
# ========================================
# LAYER 2: FUZZY MATCHES (if no exact)
# ========================================
if not matches:
logger.info(f"No exact matches found, trying fuzzy matching for: {staff_name}")
for staff in all_staff:
# Try different name combinations
name_combinations = [
f"{staff.first_name} {staff.last_name}",
f"{staff.first_name_ar} {staff.last_name_ar}",
staff.name or "",
staff.first_name,
staff.last_name,
staff.first_name_ar,
staff.last_name_ar
]
# Check if any combination matches fuzzily
best_ratio = 0.0
best_match_name = ""
for combo in name_combinations:
if not combo:
continue
ratio = fuzzy_match_ratio(staff_name, combo)
if ratio > best_ratio:
best_ratio = ratio
best_match_name = combo
# If good fuzzy match found
if best_ratio >= fuzzy_threshold:
# Adjust confidence based on match quality and department
dept_bonus = 0.05 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0
confidence = best_ratio * 0.85 + dept_bonus # Scale down slightly for fuzzy
method = f"Fuzzy match ({best_ratio:.2f}) on '{best_match_name}'"
if not any(m['id'] == str(staff.id) for m in matches):
matches.append(create_match_dict(staff, confidence, method, staff_name))
logger.info(f"FUZZY MATCH ({best_ratio:.2f}): {best_match_name} ~ {staff_name}")
# ========================================
# LAYER 3: PARTIAL/WORD MATCHES
# ========================================
if not matches:
logger.info(f"No fuzzy matches found, trying partial/word matching for: {staff_name}")
# Split input name into words
input_words = [normalize_name(w) for w in staff_name.split() if normalize_name(w)]
for staff in all_staff:
# Build list of all name fields
staff_names = [
staff.first_name,
staff.last_name,
staff.first_name_ar,
staff.last_name_ar,
staff.name or ""
]
# Count word matches
match_count = 0
total_words = len(input_words)
for word in input_words:
word_matched = False
for staff_name_field in staff_names:
if normalize_name(staff_name_field) == word or \
word in normalize_name(staff_name_field):
word_matched = True
break
if word_matched:
match_count += 1
# If at least 2 words match (or all if only 2 words)
if match_count >= 2 or (total_words == 2 and match_count == 2):
confidence = 0.60 + (match_count / total_words) * 0.15
dept_bonus = 0.05 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0
confidence += dept_bonus
method = f"Partial match ({match_count}/{total_words} words)"
if not any(m['id'] == str(staff.id) for m in matches):
matches.append(create_match_dict(staff, confidence, method, staff_name))
logger.info(f"PARTIAL MATCH ({match_count}/{total_words}): {staff.first_name} {staff.last_name}")
# ========================================
# FINAL: SORT AND RETURN
# ========================================
if matches:
# Sort by confidence (descending)
matches.sort(key=lambda x: x['confidence'], reverse=True)
best_confidence = matches[0]['confidence']
best_method = matches[0]['matching_method']
logger.info(
f"Returning {len(matches)} match(es) for '{staff_name}'. "
f"Best: {matches[0]['name_en']} (confidence: {best_confidence:.2f}, method: {best_method})"
)
if not return_all:
return str(matches[0]['id']), best_confidence, best_method
else:
return matches, best_confidence, best_method
else:
logger.warning(f"No staff match found for name: '{staff_name}'")
return [], 0.0, "No match found"
def create_match_dict(staff, confidence: float, method: str, source_name: str) -> Dict[str, Any]:
"""
Create a match dictionary for a staff member.
Args:
staff: Staff model instance
confidence: Confidence score (0.0 to 1.0)
method: Description of matching method
source_name: Original input name that was matched
Returns:
Dictionary with match details
"""
return {
'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 "",
'original_name': staff.name or "",
'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,
'confidence': confidence,
'matching_method': method,
'source_name': source_name
}
def test_enhanced_matching():
"""Test the enhanced matching function with sample data."""
from apps.organizations.models import Staff, Hospital
print("\n" + "=" * 80)
print("🧪 TESTING ENHANCED STAFF MATCHING")
print("=" * 80)
hospital = Hospital.objects.first()
if not hospital:
print("❌ No hospitals found")
return
# Test cases
test_cases = [
# Exact matches (existing staff)
("Omar Al-Harbi", "Should match exact"),
("Ahmed Al-Farsi", "Should match exact"),
("محمد الرشيد", "Should match Arabic exact"),
# Fuzzy matches (variations)
("Omar Al Harbi", "Should match without hyphen"),
("Omar Alharbi", "Should match fuzzy"),
("احمد الفارسي", "Should match Arabic fuzzy"),
# Partial matches
("Omar", "Should match first name"),
("Al-Harbi", "Should match last name"),
# Non-existent (for testing suggestions)
("Ibrahim Abdulaziz Al-Shammari", "Non-existent staff"),
]
for name, description in test_cases:
print(f"\n🔍 Testing: '{name}'")
print(f" Expected: {description}")
matches, confidence, method = match_staff_from_name_enhanced(
staff_name=name,
hospital_id=str(hospital.id),
return_all=True,
fuzzy_threshold=0.65
)
if matches:
print(f" ✅ Found {len(matches)} match(es)")
print(f" Best confidence: {confidence:.2f}")
print(f" Method: {method}")
for i, match in enumerate(matches[:3], 1):
print(f" {i}. {match['name_en']} ({match['name_ar']}) - {match['confidence']:.2f}")
if match['original_name']:
print(f" Original: {match['original_name']}")
else:
print(f" ❌ No matches found")
print(f" Confidence: {confidence:.2f}")
print(f" Method: {method}")
if __name__ == '__main__':
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
django.setup()
test_enhanced_matching()

View File

@ -6,9 +6,15 @@ from django import template
register = template.Library()
@register.filter
def get_token(obj):
"""Safely get token from explanation object to avoid linter errors"""
return obj.token if obj else None
@register.filter
def mul(value, arg):
"""Multiply the value by the argument"""
"""Multiply value by argument"""
try:
return float(value) * float(arg)
except (ValueError, TypeError):
@ -17,7 +23,7 @@ def mul(value, arg):
@register.filter
def div(value, arg):
"""Divide the value by the argument"""
"""Divide value by the argument"""
try:
return float(value) / float(arg)
except (ValueError, TypeError, ZeroDivisionError):

File diff suppressed because it is too large Load Diff

View File

@ -10,63 +10,75 @@ from .views import (
)
from . import ui_views
app_name = 'complaints'
app_name = "complaints"
router = DefaultRouter()
router.register(r'api/complaints', ComplaintViewSet, basename='complaint-api')
router.register(r'api/attachments', ComplaintAttachmentViewSet, basename='complaint-attachment-api')
router.register(r'api/inquiries', InquiryViewSet, basename='inquiry-api')
router.register(r"api/complaints", ComplaintViewSet, basename="complaint-api")
router.register(r"api/attachments", ComplaintAttachmentViewSet, basename="complaint-attachment-api")
router.register(r"api/inquiries", InquiryViewSet, basename="inquiry-api")
urlpatterns = [
# Complaints UI Views
path('', ui_views.complaint_list, name='complaint_list'),
path('new/', ui_views.complaint_create, name='complaint_create'),
path('<uuid:pk>/', ui_views.complaint_detail, name='complaint_detail'),
path('<uuid:pk>/assign/', ui_views.complaint_assign, name='complaint_assign'),
path('<uuid:pk>/change-status/', ui_views.complaint_change_status, name='complaint_change_status'),
path('<uuid:pk>/change-department/', ui_views.complaint_change_department, name='complaint_change_department'),
path('<uuid:pk>/add-note/', ui_views.complaint_add_note, name='complaint_add_note'),
path('<uuid:pk>/escalate/', ui_views.complaint_escalate, name='complaint_escalate'),
path("", ui_views.complaint_list, name="complaint_list"),
path("new/", ui_views.complaint_create, name="complaint_create"),
path("<uuid:pk>/", ui_views.complaint_detail, name="complaint_detail"),
path("<uuid:pk>/assign/", ui_views.complaint_assign, name="complaint_assign"),
path("<uuid:pk>/change-status/", ui_views.complaint_change_status, name="complaint_change_status"),
path("<uuid:pk>/change-department/", ui_views.complaint_change_department, name="complaint_change_department"),
path("<uuid:pk>/add-note/", ui_views.complaint_add_note, name="complaint_add_note"),
path("<uuid:pk>/escalate/", ui_views.complaint_escalate, name="complaint_escalate"),
# Export Views
path('export/csv/', ui_views.complaint_export_csv, name='complaint_export_csv'),
path('export/excel/', ui_views.complaint_export_excel, name='complaint_export_excel'),
path("export/csv/", ui_views.complaint_export_csv, name="complaint_export_csv"),
path("export/excel/", ui_views.complaint_export_excel, name="complaint_export_excel"),
# Bulk Actions
path('bulk/assign/', ui_views.complaint_bulk_assign, name='complaint_bulk_assign'),
path('bulk/status/', ui_views.complaint_bulk_status, name='complaint_bulk_status'),
path('bulk/escalate/', ui_views.complaint_bulk_escalate, name='complaint_bulk_escalate'),
path("bulk/assign/", ui_views.complaint_bulk_assign, name="complaint_bulk_assign"),
path("bulk/status/", ui_views.complaint_bulk_status, name="complaint_bulk_status"),
path("bulk/escalate/", ui_views.complaint_bulk_escalate, name="complaint_bulk_escalate"),
# Inquiries UI Views
path('inquiries/', ui_views.inquiry_list, name='inquiry_list'),
path('inquiries/new/', ui_views.inquiry_create, name='inquiry_create'),
path('inquiries/<uuid:pk>/', ui_views.inquiry_detail, name='inquiry_detail'),
path('inquiries/<uuid:pk>/assign/', ui_views.inquiry_assign, name='inquiry_assign'),
path('inquiries/<uuid:pk>/change-status/', ui_views.inquiry_change_status, name='inquiry_change_status'),
path('inquiries/<uuid:pk>/add-note/', ui_views.inquiry_add_note, name='inquiry_add_note'),
path('inquiries/<uuid:pk>/respond/', ui_views.inquiry_respond, name='inquiry_respond'),
path("inquiries/", ui_views.inquiry_list, name="inquiry_list"),
path("inquiries/new/", ui_views.inquiry_create, name="inquiry_create"),
path("inquiries/<uuid:pk>/", ui_views.inquiry_detail, name="inquiry_detail"),
path("inquiries/<uuid:pk>/assign/", ui_views.inquiry_assign, name="inquiry_assign"),
path("inquiries/<uuid:pk>/change-status/", ui_views.inquiry_change_status, name="inquiry_change_status"),
path("inquiries/<uuid:pk>/add-note/", ui_views.inquiry_add_note, name="inquiry_add_note"),
path("inquiries/<uuid:pk>/respond/", ui_views.inquiry_respond, name="inquiry_respond"),
# Analytics
path('analytics/', ui_views.complaints_analytics, name='complaints_analytics'),
path("analytics/", ui_views.complaints_analytics, name="complaints_analytics"),
# SLA Configuration Management
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/<uuid:pk>/edit/", ui_views.sla_config_edit, name="sla_config_edit"),
path("settings/sla/<uuid:pk>/delete/", ui_views.sla_config_delete, name="sla_config_delete"),
# 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"),
path("settings/escalation-rules/<uuid:pk>/edit/", ui_views.escalation_rule_edit, name="escalation_rule_edit"),
path("settings/escalation-rules/<uuid:pk>/delete/", ui_views.escalation_rule_delete, name="escalation_rule_delete"),
# Complaint Thresholds Management
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/<uuid:pk>/edit/", ui_views.complaint_threshold_edit, name="complaint_threshold_edit"),
path("settings/thresholds/<uuid:pk>/delete/", ui_views.complaint_threshold_delete, name="complaint_threshold_delete"),
# 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'),
path('ajax/search-patients/', ui_views.search_patients, name='search_patients'),
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"),
path("ajax/search-patients/", ui_views.search_patients, name="search_patients"),
# Public Complaint Form (No Authentication Required)
path('public/submit/', ui_views.public_complaint_submit, name='public_complaint_submit'),
path('public/success/<str:reference>/', ui_views.public_complaint_success, name='public_complaint_success'),
path('public/api/lookup-patient/', ui_views.api_lookup_patient, name='api_lookup_patient'),
path('public/api/load-departments/', ui_views.api_load_departments, name='api_load_departments'),
path('public/api/load-categories/', ui_views.api_load_categories, name='api_load_categories'),
path("public/submit/", ui_views.public_complaint_submit, name="public_complaint_submit"),
path("public/success/<str:reference>/", ui_views.public_complaint_success, name="public_complaint_success"),
path("public/api/lookup-patient/", ui_views.api_lookup_patient, name="api_lookup_patient"),
path("public/api/load-departments/", ui_views.api_load_departments, name="api_load_departments"),
path("public/api/load-categories/", ui_views.api_load_categories, name="api_load_categories"),
# Public Explanation Form (No Authentication Required)
path('<uuid:complaint_id>/explain/<str:token>/', complaint_explanation_form, name='complaint_explanation_form'),
path("<uuid:complaint_id>/explain/<str:token>/", complaint_explanation_form, name="complaint_explanation_form"),
# Resend Explanation
path(
"<uuid:pk>/resend-explanation/",
ComplaintViewSet.as_view({"post": "resend_explanation"}),
name="complaint_resend_explanation",
),
# PDF Export
path('<uuid:pk>/pdf/', generate_complaint_pdf, name='complaint_pdf'),
path("<uuid:pk>/pdf/", generate_complaint_pdf, name="complaint_pdf"),
# API Routes
path('', include(router.urls)),
path("", include(router.urls)),
]

View File

@ -2,6 +2,7 @@
Complaints views and viewsets
"""
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.decorators import action
@ -157,6 +158,31 @@ class ComplaintViewSet(viewsets.ModelViewSet):
return queryset.none()
def get_object(self):
"""
Override get_object to allow PX Admins to access complaints
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'
]:
# 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
@ -175,13 +201,13 @@ class ComplaintViewSet(viewsets.ModelViewSet):
}
)
# TODO: Optionally create PX Action (Phase 6)
# from apps.complaints.tasks import create_action_from_complaint
# create_action_from_complaint.delay(str(complaint.id))
# 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'])
def assign(self, request, pk=None):
"""Assign complaint to user"""
"""Assign complaint to user (PX Admin or Hospital Admin)"""
complaint = self.get_object()
user_id = request.data.get('user_id')
@ -194,23 +220,42 @@ class ComplaintViewSet(viewsets.ModelViewSet):
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()}",
created_by=request.user
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()}",
description=f"Complaint assigned to {user.get_full_name()} ({roles_display})",
request=request,
content_object=complaint
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'})
@ -219,6 +264,75 @@ class ComplaintViewSet(viewsets.ModelViewSet):
{'error': 'User not found'},
status=status.HTTP_404_NOT_FOUND
)
@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
)
from apps.accounts.models import User
# Get search parameter
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')
# 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)
)
# 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
})
@action(detail=True, methods=['post'])
def change_status(self, request, pk=None):
@ -436,7 +550,9 @@ class ComplaintViewSet(viewsets.ModelViewSet):
# Update complaint
old_staff_id = str(complaint.staff.id) if complaint.staff else None
complaint.staff = staff
complaint.save(update_fields=['staff'])
# Auto-set department from staff
complaint.department = staff.department
complaint.save(update_fields=['staff', 'department'])
# Update metadata to clear review flag
if not complaint.metadata:
@ -546,42 +662,21 @@ class ComplaintViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'])
def create_action_from_ai(self, request, pk=None):
"""Create PX Action from AI-suggested action"""
"""Create PX Action using AI service to generate action details from complaint"""
complaint = self.get_object()
# Check if complaint has suggested action
suggested_action = request.data.get('suggested_action')
if not suggested_action and complaint.metadata and 'ai_analysis' in complaint.metadata:
suggested_action = complaint.metadata['ai_analysis'].get('suggested_action_en')
if not suggested_action:
# 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': 'No suggested action available for this complaint'},
status=status.HTTP_400_BAD_REQUEST
{'error': f'Failed to generate action data: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Get category (optional - will be auto-mapped from complaint category if not provided)
category = request.data.get('category')
# If category not provided, auto-map from complaint category
if not category:
if complaint.category:
category = map_complaint_category_to_action_category(complaint.category.code)
else:
category = 'other'
# Validate category choice if manually provided
valid_categories = [
'clinical_quality', 'patient_safety', 'service_quality',
'staff_behavior', 'facility', 'process_improvement', 'other'
]
if category not in valid_categories:
return Response(
{'error': f'Invalid category. Valid options: {", ".join(valid_categories)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Get optional assigned_to
# Get optional assigned_to from request (AI doesn't assign by default)
assigned_to_id = request.data.get('assigned_to')
assigned_to = None
if assigned_to_id:
@ -604,19 +699,20 @@ class ComplaintViewSet(viewsets.ModelViewSet):
source_type='complaint',
content_type=complaint_content_type,
object_id=complaint.id,
title=f"Action from Complaint: {complaint.title}",
description=suggested_action,
title=action_data['title'],
description=action_data['description'],
hospital=complaint.hospital,
department=complaint.department,
category=category,
priority=complaint.priority,
severity=complaint.severity,
category=action_data['category'],
priority=action_data['priority'],
severity=action_data['severity'],
assigned_to=assigned_to,
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
}
)
@ -625,11 +721,14 @@ class ComplaintViewSet(viewsets.ModelViewSet):
PXActionLog.objects.create(
action=action,
log_type='note',
message=f"Action created from AI-suggested action for complaint: {complaint.title}",
message=f"Action generated by AI for complaint: {complaint.title}",
created_by=request.user,
metadata={
'complaint_id': str(complaint.id),
'ai_generated': True
'ai_generated': True,
'category': action_data['category'],
'priority': action_data['priority'],
'severity': action_data['severity']
}
)
@ -637,27 +736,35 @@ class ComplaintViewSet(viewsets.ModelViewSet):
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='note',
message=f"PX Action created from AI-suggested action (Action #{action.id})",
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)}
metadata={'action_id': str(action.id), 'category': action_data['category']}
)
# Log audit
AuditService.log_from_request(
event_type='action_created_from_ai',
description=f"PX Action created from AI-suggested action for complaint: {complaint.title}",
description=f"PX Action created from AI analysis for complaint: {complaint.title}",
request=request,
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'category': category,
'ai_generated': True
'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-suggested action'
'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'])
@ -1018,6 +1125,345 @@ This is an automated message from PX360 Complaint Management System.
'recipient': recipient_display,
'explanation_link': explanation_link
})
@action(detail=True, methods=['post'])
def resend_explanation(self, request, pk=None):
"""
Resend explanation request email to staff member.
Regenerates the token with a new value and resends the email.
Only allows resending if explanation has not been submitted yet.
"""
complaint = self.get_object()
# 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
)
# Check if explanation exists for this staff
from .models import ComplaintExplanation
try:
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
)
# 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
)
# 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
recipient_display = str(complaint.staff)
elif complaint.staff.email:
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
)
# Send email with new link (reuse existing email logic)
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},
We have resent the explanation request for the following complaint:
COMPLAINT DETAILS:
----------------
Reference: #{complaint.id}
Title: {complaint.title}
Severity: {complaint.get_severity_display()}
Priority: {complaint.get_priority_display()}
Status: {complaint.get_status_display()}
{complaint.description}
"""
# Add patient info if available
if complaint.patient:
email_body += f"""
PATIENT INFORMATION:
------------------
Name: {complaint.patient.get_full_name()}
MRN: {complaint.patient.mrn}
"""
email_body += f"""
SUBMIT YOUR EXPLANATION:
------------------------
Your perspective is important. Please submit your explanation about this complaint:
{explanation_link}
Note: This link can only be used once. After submission, it will expire.
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(
email=recipient_email,
subject=subject,
message=email_body,
related_object=complaint,
metadata={
'notification_type': 'explanation_request_resent',
'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
)
# Create ComplaintUpdate entry
ComplaintUpdate.objects.create(
complaint=complaint,
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
}
)
# Log audit
AuditService.log_from_request(
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)
}
)
return Response({
'success': True,
'message': 'Explanation request resent successfully',
'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 convert_to_appreciation(self, request, pk=None):
"""
Convert complaint to appreciation.
Creates an Appreciation record from a complaint marked as 'appreciation' type.
Maps complaint data to appreciation fields and links both records.
Optionally closes the complaint after conversion.
"""
complaint = self.get_object()
# Check if complaint is appreciation type
if complaint.complaint_type != 'appreciation':
return Response(
{'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'):
return Response(
{'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)
# Validate recipient
from django.contrib.contenttypes.models import ContentType
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':
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
)
else:
return Response(
{'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
)
# Determine sender (patient or anonymous)
sender = None
if not is_anonymous and complaint.patient and complaint.patient.user:
sender = complaint.patient.user
# Create Appreciation
from apps.appreciation.models import Appreciation
appreciation = Appreciation.objects.create(
sender=sender,
recipient_content_type=recipient_content_type,
recipient_object_id=recipient_object_id,
hospital=complaint.hospital,
department=complaint.department,
category=category,
message_en=message_en,
message_ar=message_ar,
visibility=visibility,
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()
}
)
# Send appreciation (triggers notification)
appreciation.send()
# 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'])
# Close complaint if requested
complaint_closed = False
if close_complaint:
complaint.status = 'closed'
complaint.closed_at = timezone.now()
complaint.closed_by = request.user
complaint.save(update_fields=['status', 'closed_at', 'closed_by'])
complaint_closed = True
# Create status update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='status_change',
message="Complaint closed after converting to appreciation",
created_by=request.user,
old_status='open',
new_status='closed'
)
# Create conversion update
ComplaintUpdate.objects.create(
complaint=complaint,
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
}
)
# Log audit
AuditService.log_from_request(
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
}
)
# 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)
class ComplaintAttachmentViewSet(viewsets.ModelViewSet):

View File

@ -208,7 +208,7 @@ class AIService:
hospital_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Analyze a complaint and determine title, severity, priority, category, subcategory, and department.
Analyze a complaint and determine type (complaint vs appreciation), title, severity, priority, category, subcategory, and department.
Args:
title: Complaint title (optional, will be generated if not provided)
@ -219,6 +219,7 @@ class AIService:
Returns:
Dictionary with analysis:
{
'complaint_type': 'complaint' | 'appreciation', # Type of feedback
'title': str, # Generated or provided title
'short_description': str, # 2-3 sentence summary of the complaint
'severity': 'low' | 'medium' | 'high' | 'critical',
@ -284,10 +285,10 @@ class AIService:
5. If a category has no subcategories, leave the subcategory field empty
6. Select the most appropriate department from the hospital's departments (if available)
7. If no departments are available or department is unclear, leave the department field empty
8. Extract any staff members mentioned in the complaint (physicians, nurses, etc.)
9. Return the staff name WITHOUT titles (Dr., Nurse, دكتور, ممرض, etc.)
10. If multiple staff are mentioned, return the PRIMARY one
11. If no staff is mentioned, leave the staff_name field empty
8. Extract ALL staff members mentioned in the complaint (physicians, nurses, etc.)
9. Return ALL staff names WITHOUT titles (Dr., Nurse, دكتور, ممرض, etc.)
10. Identify the PRIMARY staff member (the one most relevant to the complaint)
11. If no staff is mentioned, return empty arrays for staff names
12. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic
IMPORTANT: ALL TEXT FIELDS MUST BE PROVIDED IN BOTH ENGLISH AND ARABIC
@ -307,7 +308,8 @@ class AIService:
"category": "exact category name from the list above",
"subcategory": "exact subcategory name from the chosen category, or empty string if not applicable",
"department": "exact department name from the hospital's departments, or empty string if not applicable",
"staff_name": "name of staff member mentioned (without titles like Dr., Nurse, etc.), or empty string if no staff mentioned",
"staff_names": ["name1", "name2", "name3"],
"primary_staff_name": "name of PRIMARY staff member (the one most relevant to the complaint), or empty string if no staff mentioned",
"suggested_action_en": "2-3 specific, actionable steps in English to address this complaint",
"suggested_action_ar": "خطوات محددة وعمليه بالعربية",
"reasoning_en": "Brief explanation in English of your classification (2-3 sentences)",
@ -331,6 +333,10 @@ class AIService:
# Parse JSON response
result = json.loads(response)
# Detect complaint type
complaint_type = cls._detect_complaint_type(description + " " + (title or ""))
result['complaint_type'] = complaint_type
# Use provided title if available, otherwise use AI-generated title
if title:
result['title'] = title
@ -591,5 +597,274 @@ class AIService:
logger.error(f"Summary generation failed: {e}")
return text[:max_length]
@classmethod
def create_px_action_from_complaint(cls, complaint) -> Dict[str, Any]:
"""
Generate PX Action data from a complaint using AI analysis.
Args:
complaint: Complaint model instance
Returns:
Dictionary with PX Action data:
{
'title': str,
'description': str,
'category': str,
'priority': str,
'severity': str,
'reasoning': str
}
"""
# Get complaint data
title = complaint.title
description = complaint.description
complaint_category = complaint.category.name_en if complaint.category else 'other'
severity = complaint.severity
priority = complaint.priority
# Build prompt for AI to generate action details
prompt = f"""Generate a PX Action from this complaint:
Complaint Title: {title}
Complaint Description: {description}
Complaint Category: {complaint_category}
Severity: {severity}
Priority: {priority}
Available PX Action Categories:
- clinical_quality: Issues related to medical care quality, diagnosis, treatment
- patient_safety: Issues that could harm patients, safety violations, risks
- service_quality: Issues with service delivery, wait times, customer service
- staff_behavior: Issues with staff professionalism, attitude, conduct
- facility: Issues with facilities, equipment, environment, cleanliness
- process_improvement: Issues with processes, workflows, procedures
- other: General issues that don't fit specific categories
Instructions:
1. Generate a clear, action-oriented title for the PX Action (max 15 words)
2. Create a detailed description that explains what needs to be done
3. Select the most appropriate PX Action category from the list above
4. Keep the same severity and priority as the complaint
5. Provide reasoning for your choices
Provide your response in JSON format:
{{
"title": "Action-oriented title (max 15 words)",
"description": "Detailed description of what needs to be done to address this complaint",
"category": "exact category name from the list above",
"priority": "low|medium|high",
"severity": "low|medium|high|critical",
"reasoning": "Brief explanation of why this category and action are appropriate"
}}"""
system_prompt = """You are a healthcare quality improvement expert.
Generate PX Actions that are actionable, specific, and focused on improvement.
The action should clearly state what needs to be done to address the complaint.
Be specific and practical in your descriptions."""
try:
response = cls.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
response_format="json_object",
temperature=0.3
)
# Parse JSON response
result = json.loads(response)
# Validate category
valid_categories = [
'clinical_quality', 'patient_safety', 'service_quality',
'staff_behavior', 'facility', 'process_improvement', 'other'
]
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)
# Validate severity
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
logger.info(f"PX Action generated: title={result['title']}, category={result['category']}")
return result
except json.JSONDecodeError as e:
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'
}
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)}'
}
@classmethod
def _map_category_to_action_category(cls, complaint_category: str) -> str:
"""
Map complaint category to PX Action category.
Args:
complaint_category: Complaint category name
Returns:
PX Action category name
"""
# Normalize category name (lowercase, remove spaces)
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',
# Safety categories
'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',
# 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',
# Process
'process': 'process_improvement',
'workflow': 'process_improvement',
'procedure': 'process_improvement',
'policy': 'process_improvement',
}
# Check for partial matches
for key, value in mapping.items():
if key in category_lower:
return value
# Default to 'other' if no match found
return 'other'
@classmethod
def _detect_complaint_type(cls, text: str) -> str:
"""
Detect if the text is a complaint or appreciation using sentiment and keywords.
Args:
text: Text to analyze
Returns:
'complaint' or 'appreciation'
"""
# 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'
]
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'
]
complaint_keywords_ar = [
'مشكلة', 'مشاكل', 'سيء', 'مخيب', 'سيء للغاية',
'تعيس', 'ضعيف', 'أسوأ', 'غير مقبول', 'فظ',
'بطيء', 'تأخير', 'انتظار', 'إهمال', 'تجاهل',
'غاضب', 'محبط', 'غير راضي', 'قلق'
]
text_lower = text.lower()
# Count keyword matches
appreciation_count = 0
complaint_count = 0
for keyword in appreciation_keywords_en + appreciation_keywords_ar:
if keyword in text_lower:
appreciation_count += 1
for keyword in complaint_keywords_en + complaint_keywords_ar:
if keyword in text_lower:
complaint_count += 1
# Get sentiment analysis
try:
sentiment_result = cls.classify_sentiment(text)
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 appreciation_count >= complaint_count:
return 'appreciation'
# If sentiment is clearly negative
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'
elif complaint_count > appreciation_count:
return 'complaint'
else:
# No clear indicators, default to complaint
return 'complaint'
# Convenience singleton instance
ai_service = AIService()

View File

@ -1,39 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AuditEvent',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('event_type', models.CharField(choices=[('user_login', 'User Login'), ('user_logout', 'User Logout'), ('role_change', 'Role Change'), ('status_change', 'Status Change'), ('assignment', 'Assignment'), ('escalation', 'Escalation'), ('sla_breach', 'SLA Breach'), ('survey_sent', 'Survey Sent'), ('survey_completed', 'Survey Completed'), ('action_created', 'Action Created'), ('action_closed', 'Action Closed'), ('complaint_created', 'Complaint Created'), ('complaint_closed', 'Complaint Closed'), ('journey_started', 'Journey Started'), ('journey_completed', 'Journey Completed'), ('stage_completed', 'Stage Completed'), ('integration_event', 'Integration Event'), ('notification_sent', 'Notification Sent'), ('other', 'Other')], db_index=True, max_length=50)),
('description', models.TextField()),
('object_id', models.UUIDField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_events', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['event_type', '-created_at'], name='core_audite_event_t_2e3170_idx'), models.Index(fields=['user', '-created_at'], name='core_audite_user_id_14c149_idx'), models.Index(fields=['content_type', 'object_id'], name='core_audite_content_7c950d_idx')],
},
),
]

View File

@ -59,25 +59,25 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
complaints_qs = Complaint.objects.filter(hospital=hospital) if hospital else Complaint.objects.none()
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.filter(hospital=hospital) if hospital else SocialMention.objects.none()
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()
elif user.is_hospital_admin() and user.hospital:
complaints_qs = Complaint.objects.filter(hospital=user.hospital)
actions_qs = PXAction.objects.filter(hospital=user.hospital)
surveys_qs = SurveyInstance.objects.filter(survey_template__hospital=user.hospital)
social_qs = SocialMediaComment.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)
elif user.is_department_manager() and user.department:
complaints_qs = Complaint.objects.filter(department=user.department)
actions_qs = PXAction.objects.filter(department=user.department)
surveys_qs = SurveyInstance.objects.filter(journey_stage_instance__department=user.department)
social_qs = SocialMediaComment.objects.filter(department=user.department)
social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not department-specific
calls_qs = CallCenterInteraction.objects.filter(department=user.department)
else:
complaints_qs = Complaint.objects.none()
actions_qs = PXAction.objects.none()
surveys_qs = SurveyInstance.objects.none()
social_qs = SocialMediaComment.objects.none()
social_qs = SocialMediaComment.objects.all() # Show all social media comments
calls_qs = CallCenterInteraction.objects.none()
# Top KPI Stats

View File

@ -1,96 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='FeedbackAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='feedback/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FeedbackResponse',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('response_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Internal Note'), ('response', 'Response to Patient'), ('acknowledgment', 'Acknowledgment')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('is_internal', models.BooleanField(default=False, help_text='Internal note (not visible to patient)')),
('metadata', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Feedback',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_anonymous', models.BooleanField(default=False)),
('contact_name', models.CharField(blank=True, max_length=200)),
('contact_email', models.EmailField(blank=True, max_length=254)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)),
('feedback_type', models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry'), ('satisfaction_check', 'Satisfaction Check')], db_index=True, default='general', max_length=20)),
('title', models.CharField(max_length=500)),
('message', models.TextField(help_text='Feedback message')),
('category', models.CharField(choices=[('clinical_care', 'Clinical Care'), ('staff_service', 'Staff Service'), ('facility', 'Facility & Environment'), ('communication', 'Communication'), ('appointment', 'Appointment & Scheduling'), ('billing', 'Billing & Insurance'), ('food_service', 'Food Service'), ('cleanliness', 'Cleanliness'), ('technology', 'Technology & Systems'), ('other', 'Other')], db_index=True, max_length=50)),
('subcategory', models.CharField(blank=True, max_length=100)),
('rating', models.IntegerField(blank=True, help_text='Rating from 1 to 5 stars', null=True)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
('sentiment', models.CharField(choices=[('positive', 'Positive'), ('neutral', 'Neutral'), ('negative', 'Negative')], db_index=True, default='neutral', help_text='Sentiment analysis result', max_length=20)),
('sentiment_score', models.FloatField(blank=True, help_text='Sentiment score from -1 (negative) to 1 (positive)', null=True)),
('status', models.CharField(choices=[('submitted', 'Submitted'), ('reviewed', 'Reviewed'), ('acknowledged', 'Acknowledged'), ('closed', 'Closed')], db_index=True, default='submitted', max_length=20)),
('assigned_at', models.DateTimeField(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('acknowledged_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('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)),
('metadata', models.JSONField(blank=True, default=dict)),
('is_deleted', models.BooleanField(db_index=True, default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('acknowledged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acknowledged_feedbacks', to=settings.AUTH_USER_MODEL)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_feedbacks', to=settings.AUTH_USER_MODEL)),
('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_feedbacks', to=settings.AUTH_USER_MODEL)),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_feedbacks', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.hospital')),
('patient', models.ForeignKey(blank=True, help_text='Patient who provided feedback (optional for anonymous feedback)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.patient')),
],
options={
'verbose_name_plural': 'Feedback',
'ordering': ['-created_at'],
},
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('feedback', '0001_initial'),
('surveys', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='feedback',
name='related_survey',
field=models.ForeignKey(blank=True, help_text='Survey that triggered this satisfaction check feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='follow_up_feedbacks', to='surveys.surveyinstance'),
),
migrations.AddField(
model_name='feedback',
name='reviewed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_feedbacks', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,74 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('feedback', '0002_initial'),
('organizations', '0001_initial'),
('px_sources', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='feedback',
name='source',
field=models.ForeignKey(blank=True, help_text='Source of feedback', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='feedbacks', to='px_sources.pxsource'),
),
migrations.AddField(
model_name='feedback',
name='staff',
field=models.ForeignKey(blank=True, help_text='Staff member being mentioned in feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.staff'),
),
migrations.AddField(
model_name='feedbackattachment',
name='feedback',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='feedback.feedback'),
),
migrations.AddField(
model_name='feedbackattachment',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_attachments', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='feedbackresponse',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_responses', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='feedbackresponse',
name='feedback',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='feedback.feedback'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['status', '-created_at'], name='feedback_fe_status_212662_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='feedback_fe_hospita_4c1146_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['feedback_type', '-created_at'], name='feedback_fe_feedbac_6b63a4_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['sentiment', '-created_at'], name='feedback_fe_sentime_443190_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['is_deleted', '-created_at'], name='feedback_fe_is_dele_f543d5_idx'),
),
migrations.AddIndex(
model_name='feedbackresponse',
index=models.Index(fields=['feedback', '-created_at'], name='feedback_fe_feedbac_bc9e33_idx'),
),
]

View File

@ -1,77 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='IntegrationConfig',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200, unique=True)),
('source_system', models.CharField(choices=[('his', 'Hospital Information System'), ('lab', 'Laboratory System'), ('radiology', 'Radiology System'), ('pharmacy', 'Pharmacy System'), ('moh', 'Ministry of Health'), ('chi', 'Council of Health Insurance'), ('pxconnect', 'PX Connect'), ('other', 'Other')], max_length=50, unique=True)),
('api_url', models.URLField(blank=True, help_text='API endpoint URL')),
('api_key', models.CharField(blank=True, help_text='API key (encrypted)', max_length=500)),
('is_active', models.BooleanField(default=True)),
('config_json', models.JSONField(blank=True, default=dict, help_text='Additional configuration (event mappings, field mappings, etc.)')),
('description', models.TextField(blank=True)),
('last_sync_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='InboundEvent',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('source_system', models.CharField(choices=[('his', 'Hospital Information System'), ('lab', 'Laboratory System'), ('radiology', 'Radiology System'), ('pharmacy', 'Pharmacy System'), ('moh', 'Ministry of Health'), ('chi', 'Council of Health Insurance'), ('pxconnect', 'PX Connect'), ('other', 'Other')], db_index=True, help_text='System that sent this event', max_length=50)),
('event_code', models.CharField(db_index=True, help_text='Event type code (e.g., OPD_VISIT_COMPLETED, LAB_ORDER_COMPLETED)', max_length=100)),
('encounter_id', models.CharField(db_index=True, help_text='Encounter ID from HIS system', max_length=100)),
('patient_identifier', models.CharField(blank=True, db_index=True, help_text='Patient MRN or other identifier', max_length=100)),
('payload_json', models.JSONField(help_text='Full event payload from source system')),
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('processed', 'Processed'), ('failed', 'Failed'), ('ignored', 'Ignored')], db_index=True, default='pending', max_length=20)),
('received_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('processed_at', models.DateTimeField(blank=True, null=True)),
('error', models.TextField(blank=True, help_text='Error message if processing failed')),
('processing_attempts', models.IntegerField(default=0, help_text='Number of processing attempts')),
('physician_license', models.CharField(blank=True, help_text='Physician license number from event', max_length=100)),
('department_code', models.CharField(blank=True, help_text='Department code from event', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional processing metadata')),
],
options={
'ordering': ['-received_at'],
'indexes': [models.Index(fields=['status', '-received_at'], name='integration_status_f5244c_idx'), models.Index(fields=['encounter_id', 'event_code'], name='integration_encount_e7d795_idx'), models.Index(fields=['source_system', '-received_at'], name='integration_source__bacde5_idx')],
},
),
migrations.CreateModel(
name='EventMapping',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('external_event_code', models.CharField(help_text='Event code from external system', max_length=100)),
('internal_event_code', models.CharField(help_text='Internal event code used in journey stages', max_length=100)),
('field_mappings', models.JSONField(blank=True, default=dict, help_text='Maps external field names to internal field names')),
('is_active', models.BooleanField(default=True)),
('integration_config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_mappings', to='integrations.integrationconfig')),
],
options={
'ordering': ['integration_config', 'external_event_code'],
'unique_together': {('integration_config', 'external_event_code')},
},
),
]

View File

@ -11,6 +11,7 @@ import logging
from celery import shared_task
from django.db import transaction
from django.utils import timezone
logger = logging.getLogger('apps.integrations')
@ -115,21 +116,40 @@ def process_inbound_event(self, event_id):
}
)
# Check if survey should be sent
if stage_instance.stage_template.auto_send_survey and stage_instance.stage_template.survey_template:
# Queue survey creation task with delay
from apps.surveys.tasks import create_and_send_survey
delay_seconds = stage_instance.stage_template.survey_delay_hours * 3600
logger.info(
f"Queuing survey for stage {stage_instance.stage_template.name} "
f"(delay: {stage_instance.stage_template.survey_delay_hours}h)"
)
create_and_send_survey.apply_async(
args=[str(stage_instance.id)],
countdown=delay_seconds
)
# Check if this is a discharge event
if event.event_code.upper() == 'PATIENT_DISCHARGED':
logger.info(f"Discharge event received for encounter {event.encounter_id}")
# Mark journey as completed
journey_instance.status = 'completed'
journey_instance.completed_at = timezone.now()
journey_instance.save()
# Check if post-discharge survey is enabled
if journey_instance.journey_template.send_post_discharge_survey:
logger.info(
f"Post-discharge survey enabled for journey {journey_instance.id}. "
f"Will send in {journey_instance.journey_template.post_discharge_survey_delay_hours} hour(s)"
)
# Queue post-discharge survey creation task with delay
from apps.surveys.tasks import create_post_discharge_survey
delay_hours = journey_instance.journey_template.post_discharge_survey_delay_hours
delay_seconds = delay_hours * 3600
create_post_discharge_survey.apply_async(
args=[str(journey_instance.id)],
countdown=delay_seconds
)
logger.info(
f"Queued post-discharge survey for journey {journey_instance.id} "
f"(delay: {delay_hours}h)"
)
else:
logger.info(
f"Post-discharge survey disabled for journey {journey_instance.id}"
)
# Mark event as processed
event.mark_processed()

View File

@ -17,7 +17,7 @@ class PatientJourneyStageTemplateInline(admin.TabularInline):
extra = 1
fields = [
'order', 'name', 'code', 'trigger_event_code',
'survey_template', 'auto_send_survey', 'is_optional', 'is_active'
'survey_template', 'is_optional', 'is_active'
]
ordering = ['order']
@ -34,6 +34,9 @@ class PatientJourneyTemplateAdmin(admin.ModelAdmin):
fieldsets = (
(None, {'fields': ('name', 'name_ar', 'journey_type', 'description')}),
('Configuration', {'fields': ('hospital', 'is_active', 'is_default')}),
('Post-Discharge Survey', {
'fields': ('send_post_discharge_survey', 'post_discharge_survey_delay_hours')
}),
('Metadata', {'fields': ('created_at', 'updated_at')}),
)
@ -49,9 +52,9 @@ class PatientJourneyStageTemplateAdmin(admin.ModelAdmin):
"""Journey stage template admin"""
list_display = [
'name', 'journey_template', 'order', 'trigger_event_code',
'auto_send_survey', 'is_optional', 'is_active'
'is_optional', 'is_active'
]
list_filter = ['journey_template__journey_type', 'auto_send_survey', 'is_optional', 'is_active']
list_filter = ['journey_template__journey_type', 'is_optional', 'is_active']
search_fields = ['name', 'name_ar', 'code', 'trigger_event_code']
ordering = ['journey_template', 'order']
@ -59,13 +62,10 @@ class PatientJourneyStageTemplateAdmin(admin.ModelAdmin):
(None, {'fields': ('journey_template', 'name', 'name_ar', 'code', 'order')}),
('Event Trigger', {'fields': ('trigger_event_code',)}),
('Survey Configuration', {
'fields': ('survey_template', 'auto_send_survey', 'survey_delay_hours')
}),
('Requirements', {
'fields': ('requires_physician', 'requires_department')
'fields': ('survey_template',)
}),
('Configuration', {
'fields': ('is_optional', 'is_active', 'description')
'fields': ('is_optional', 'is_active')
}),
('Metadata', {'fields': ('created_at', 'updated_at')}),
)
@ -83,9 +83,9 @@ class PatientJourneyStageInstanceInline(admin.TabularInline):
extra = 0
fields = [
'stage_template', 'status', 'completed_at',
'staff', 'department', 'survey_instance'
'staff', 'department'
]
readonly_fields = ['stage_template', 'completed_at', 'survey_instance']
readonly_fields = ['stage_template', 'completed_at']
ordering = ['stage_template__order']
def has_add_permission(self, request, obj=None):
@ -139,7 +139,7 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
"""Journey stage instance admin"""
list_display = [
'journey_instance', 'stage_template', 'status',
'completed_at', 'staff', 'survey_instance'
'completed_at', 'staff'
]
list_filter = ['status', 'stage_template__journey_template__journey_type', 'completed_at']
search_fields = [
@ -154,10 +154,7 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
'fields': ('journey_instance', 'stage_template', 'status')
}),
('Completion Details', {
'fields': ('completed_at', 'completed_by_event', 'staff', 'department')
}),
('Survey', {
'fields': ('survey_instance', 'survey_sent_at')
'fields': ('completed_at', 'staff', 'department')
}),
('Metadata', {
'fields': ('metadata', 'created_at', 'updated_at'),
@ -165,7 +162,7 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
}),
)
readonly_fields = ['completed_at', 'completed_by_event', 'survey_sent_at', 'created_at', 'updated_at']
readonly_fields = ['completed_at', 'created_at', 'updated_at']
def get_queryset(self, request):
qs = super().get_queryset(request)
@ -173,7 +170,5 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
'journey_instance',
'stage_template',
'staff',
'department',
'survey_instance',
'completed_by_event'
'department'
)

92
apps/journeys/forms.py Normal file
View File

@ -0,0 +1,92 @@
"""
Journey forms for CRUD operations
"""
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import (
PatientJourneyStageTemplate,
PatientJourneyTemplate,
)
class PatientJourneyTemplateForm(forms.ModelForm):
"""Form for creating/editing journey templates"""
class Meta:
model = PatientJourneyTemplate
fields = [
'name', 'name_ar', 'hospital', 'journey_type',
'description', 'is_active',
'send_post_discharge_survey', 'post_discharge_survey_delay_hours'
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., Inpatient Journey'
}),
'name_ar': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'الاسم بالعربية'
}),
'hospital': forms.Select(attrs={'class': 'form-select'}),
'journey_type': forms.Select(attrs={'class': 'form-select'}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Describe this journey...'
}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'send_post_discharge_survey': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'post_discharge_survey_delay_hours': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0'
}),
}
class PatientJourneyStageTemplateForm(forms.ModelForm):
"""Form for creating/editing journey stage templates"""
class Meta:
model = PatientJourneyStageTemplate
fields = [
'name', 'name_ar', 'code', 'order',
'trigger_event_code', 'survey_template', 'is_optional', 'is_active'
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., Admission'
}),
'name_ar': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'الاسم بالعربية'
}),
'code': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., ADMISSION'
}),
'order': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0'
}),
'trigger_event_code': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., OPD_VISIT_COMPLETED'
}),
'survey_template': forms.Select(attrs={'class': 'form-select form-select-sm'}),
'is_optional': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
PatientJourneyStageTemplateFormSet = forms.inlineformset_factory(
PatientJourneyTemplate,
PatientJourneyStageTemplate,
form=PatientJourneyStageTemplateForm,
extra=1,
can_delete=True,
min_num=1,
validate_min=True
)

View File

@ -1,96 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('integrations', '0001_initial'),
('organizations', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PatientJourneyStageTemplate',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('code', models.CharField(help_text='Unique code for this stage (e.g., OPD_MD_CONSULT, LAB, RADIOLOGY)', max_length=50)),
('order', models.IntegerField(default=0, help_text='Order of this stage in the journey')),
('trigger_event_code', models.CharField(db_index=True, help_text='Event code that triggers completion of this stage (e.g., OPD_VISIT_COMPLETED)', max_length=100)),
('auto_send_survey', models.BooleanField(default=False, help_text='Automatically send survey when stage completes')),
('survey_delay_hours', models.IntegerField(default=0, help_text='Hours to wait before sending survey (0 = immediate)')),
('requires_physician', models.BooleanField(default=False, help_text='Does this stage require physician information?')),
('requires_department', models.BooleanField(default=False, help_text='Does this stage require department information?')),
('is_optional', models.BooleanField(default=False, help_text='Can this stage be skipped?')),
('is_active', models.BooleanField(default=True)),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['journey_template', 'order'],
},
),
migrations.CreateModel(
name='PatientJourneyTemplate',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('journey_type', models.CharField(choices=[('ems', 'EMS (Emergency Medical Services)'), ('inpatient', 'Inpatient'), ('opd', 'OPD (Outpatient Department)')], db_index=True, max_length=20)),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('is_default', models.BooleanField(default=False, help_text='Default template for this journey type in this hospital')),
],
options={
'ordering': ['hospital', 'journey_type', 'name'],
},
),
migrations.CreateModel(
name='PatientJourneyInstance',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('encounter_id', models.CharField(db_index=True, help_text='Unique encounter ID from HIS system', max_length=100, unique=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('started_at', models.DateTimeField(auto_now_add=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata from HIS system')),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_instances', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='journey_instances', to='organizations.hospital')),
('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='journeys', to='organizations.patient')),
],
options={
'ordering': ['-started_at'],
},
),
migrations.CreateModel(
name='PatientJourneyStageInstance',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('skipped', 'Skipped'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20)),
('completed_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('survey_sent_at', models.DateTimeField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional data from integration event')),
('completed_by_event', models.ForeignKey(blank=True, help_text='Integration event that completed this stage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_stages', to='integrations.inboundevent')),
('department', models.ForeignKey(blank=True, help_text='Department where this stage occurred', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='organizations.department')),
('journey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stage_instances', to='journeys.patientjourneyinstance')),
('staff', models.ForeignKey(blank=True, help_text='Staff member associated with this stage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='organizations.staff')),
],
options={
'ordering': ['journey_instance', 'stage_template__order'],
},
),
]

View File

@ -1,92 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('journeys', '0001_initial'),
('organizations', '0001_initial'),
('surveys', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='patientjourneystageinstance',
name='survey_instance',
field=models.ForeignKey(blank=True, help_text='Survey instance created for this stage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stage', to='surveys.surveyinstance'),
),
migrations.AddField(
model_name='patientjourneystagetemplate',
name='survey_template',
field=models.ForeignKey(blank=True, help_text='Survey to send when this stage completes', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='surveys.surveytemplate'),
),
migrations.AddField(
model_name='patientjourneystageinstance',
name='stage_template',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='journeys.patientjourneystagetemplate'),
),
migrations.AddField(
model_name='patientjourneytemplate',
name='hospital',
field=models.ForeignKey(help_text='Hospital this template belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='journey_templates', to='organizations.hospital'),
),
migrations.AddField(
model_name='patientjourneystagetemplate',
name='journey_template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='journeys.patientjourneytemplate'),
),
migrations.AddField(
model_name='patientjourneyinstance',
name='journey_template',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='journeys.patientjourneytemplate'),
),
migrations.AddIndex(
model_name='patientjourneystageinstance',
index=models.Index(fields=['journey_instance', 'status'], name='journeys_pa_journey_dc3289_idx'),
),
migrations.AddIndex(
model_name='patientjourneystageinstance',
index=models.Index(fields=['status', 'completed_at'], name='journeys_pa_status_563c5f_idx'),
),
migrations.AlterUniqueTogether(
name='patientjourneystageinstance',
unique_together={('journey_instance', 'stage_template')},
),
migrations.AddIndex(
model_name='patientjourneytemplate',
index=models.Index(fields=['hospital', 'journey_type', 'is_active'], name='journeys_pa_hospita_3b6b47_idx'),
),
migrations.AlterUniqueTogether(
name='patientjourneytemplate',
unique_together={('hospital', 'journey_type', 'name')},
),
migrations.AddIndex(
model_name='patientjourneystagetemplate',
index=models.Index(fields=['journey_template', 'order'], name='journeys_pa_journey_ded883_idx'),
),
migrations.AddIndex(
model_name='patientjourneystagetemplate',
index=models.Index(fields=['trigger_event_code'], name='journeys_pa_trigger_b1272a_idx'),
),
migrations.AlterUniqueTogether(
name='patientjourneystagetemplate',
unique_together={('journey_template', 'code')},
),
migrations.AddIndex(
model_name='patientjourneyinstance',
index=models.Index(fields=['encounter_id'], name='journeys_pa_encount_951b01_idx'),
),
migrations.AddIndex(
model_name='patientjourneyinstance',
index=models.Index(fields=['patient', '-started_at'], name='journeys_pa_patient_174f56_idx'),
),
migrations.AddIndex(
model_name='patientjourneyinstance',
index=models.Index(fields=['hospital', 'status', '-started_at'], name='journeys_pa_hospita_724af9_idx'),
),
]

View File

@ -59,6 +59,16 @@ class PatientJourneyTemplate(UUIDModel, TimeStampedModel):
default=False,
help_text="Default template for this journey type in this hospital"
)
# Post-discharge survey configuration
send_post_discharge_survey = models.BooleanField(
default=False,
help_text="Send a comprehensive survey after patient discharge"
)
post_discharge_survey_delay_hours = models.IntegerField(
default=1,
help_text="Hours after discharge to send the survey"
)
class Meta:
ordering = ['hospital', 'journey_type', 'name']
@ -111,31 +121,15 @@ class PatientJourneyStageTemplate(UUIDModel, TimeStampedModel):
)
# Survey configuration
# Note: survey_template is used for post-discharge survey question merging
# Auto-sending surveys after each stage has been removed
survey_template = models.ForeignKey(
'surveys.SurveyTemplate',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='journey_stages',
help_text="Survey to send when this stage completes"
)
auto_send_survey = models.BooleanField(
default=False,
help_text="Automatically send survey when stage completes"
)
survey_delay_hours = models.IntegerField(
default=0,
help_text="Hours to wait before sending survey (0 = immediate)"
)
# Requirements
requires_physician = models.BooleanField(
default=False,
help_text="Does this stage require physician information?"
)
requires_department = models.BooleanField(
default=False,
help_text="Does this stage require department information?"
help_text="Survey template containing questions for this stage (merged into post-discharge survey)"
)
# Configuration
@ -145,8 +139,6 @@ class PatientJourneyStageTemplate(UUIDModel, TimeStampedModel):
)
is_active = models.BooleanField(default=True)
description = models.TextField(blank=True)
class Meta:
ordering = ['journey_template', 'order']
unique_together = [['journey_template', 'code']]
@ -284,14 +276,6 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
# Completion details
completed_at = models.DateTimeField(null=True, blank=True, db_index=True)
completed_by_event = models.ForeignKey(
'integrations.InboundEvent',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='completed_stages',
help_text="Integration event that completed this stage"
)
# Context from event
staff = models.ForeignKey(
@ -311,17 +295,6 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
help_text="Department where this stage occurred"
)
# Survey tracking
survey_instance = models.ForeignKey(
'surveys.SurveyInstance',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='journey_stage',
help_text="Survey instance created for this stage"
)
survey_sent_at = models.DateTimeField(null=True, blank=True)
# Metadata
metadata = models.JSONField(
default=dict,
@ -344,7 +317,7 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
"""Check if this stage can be completed"""
return self.status in [StageStatus.PENDING, StageStatus.IN_PROGRESS]
def complete(self, event=None, staff=None, department=None, metadata=None):
def complete(self, staff=None, department=None, metadata=None):
"""
Mark stage as completed.
@ -352,8 +325,7 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
It will:
1. Update status to COMPLETED
2. Set completion timestamp
3. Attach event, staff, department
4. Trigger survey creation if configured
3. Attach staff, department
"""
from django.utils import timezone
@ -362,7 +334,6 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
self.status = StageStatus.COMPLETED
self.completed_at = timezone.now()
self.completed_by_event = event
if staff:
self.staff = staff

View File

@ -20,9 +20,7 @@ class PatientJourneyStageTemplateSerializer(serializers.ModelSerializer):
fields = [
'id', 'journey_template', 'name', 'name_ar', 'code', 'order',
'trigger_event_code', 'survey_template', 'survey_template_name',
'auto_send_survey', 'survey_delay_hours',
'requires_physician', 'requires_department',
'is_optional', 'is_active', 'description',
'is_optional', 'is_active',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
@ -55,20 +53,17 @@ class PatientJourneyStageInstanceSerializer(serializers.ModelSerializer):
stage_order = serializers.IntegerField(source='stage_template.order', read_only=True)
staff_name = serializers.SerializerMethodField()
department_name = serializers.CharField(source='department.name', read_only=True)
survey_status = serializers.CharField(source='survey_instance.status', read_only=True)
class Meta:
model = PatientJourneyStageInstance
fields = [
'id', 'journey_instance', 'stage_template', 'stage_name', 'stage_order',
'status', 'completed_at', 'completed_by_event',
'status', 'completed_at',
'staff', 'staff_name', 'department', 'department_name',
'survey_instance', 'survey_status', 'survey_sent_at',
'metadata', 'created_at', 'updated_at'
]
read_only_fields = [
'id', 'completed_at', 'completed_by_event',
'survey_instance', 'survey_sent_at',
'id', 'completed_at',
'created_at', 'updated_at'
]

View File

@ -1,17 +1,23 @@
"""
Journey Console UI views - Server-rendered templates for journey monitoring
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, Count, Prefetch
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from apps.organizations.models import Department, Hospital
from .forms import (
PatientJourneyStageTemplateFormSet,
PatientJourneyTemplateForm,
)
from .models import (
PatientJourneyInstance,
PatientJourneyStageInstance,
PatientJourneyStageTemplate,
PatientJourneyTemplate,
StageStatus,
)
@ -37,7 +43,7 @@ def journey_instance_list(request):
).prefetch_related(
'stage_instances__stage_template',
'stage_instances__staff',
'stage_instances__survey_instance'
'stage_instances__department'
)
# Apply RBAC filters
@ -147,9 +153,7 @@ def journey_instance_detail(request, pk):
).prefetch_related(
'stage_instances__stage_template',
'stage_instances__staff',
'stage_instances__department',
'stage_instances__survey_instance',
'stage_instances__completed_by_event'
'stage_instances__department'
),
pk=pk
)
@ -230,3 +234,136 @@ def journey_template_list(request):
}
return render(request, 'journeys/template_list.html', context)
@login_required
def journey_template_create(request):
"""Create a new journey template with stages"""
# 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 journey templates.")
return redirect('journeys:template_list')
if request.method == 'POST':
form = PatientJourneyTemplateForm(request.POST)
formset = PatientJourneyStageTemplateFormSet(request.POST)
if form.is_valid() and formset.is_valid():
template = form.save(commit=False)
template.save()
stages = formset.save(commit=False)
for stage in stages:
stage.journey_template = template
stage.save()
messages.success(request, "Journey template created successfully.")
return redirect('journeys:template_detail', pk=template.pk)
else:
form = PatientJourneyTemplateForm()
formset = PatientJourneyStageTemplateFormSet()
context = {
'form': form,
'formset': formset,
}
return render(request, 'journeys/template_form.html', context)
@login_required
def journey_template_detail(request, pk):
"""View journey template details"""
template = get_object_or_404(
PatientJourneyTemplate.objects.select_related('hospital').prefetch_related(
'stages__survey_template'
),
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('journeys:template_list')
# Get statistics
total_instances = template.instances.count()
active_instances = template.instances.filter(status='active').count()
completed_instances = template.instances.filter(status='completed').count()
stages = template.stages.all().order_by('order')
context = {
'template': template,
'stages': stages,
'stats': {
'total_instances': total_instances,
'active_instances': active_instances,
'completed_instances': completed_instances,
}
}
return render(request, 'journeys/template_detail.html', context)
@login_required
def journey_template_edit(request, pk):
"""Edit an existing journey template with stages"""
template = get_object_or_404(PatientJourneyTemplate, 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 edit this template.")
return redirect('journeys:template_list')
if request.method == 'POST':
form = PatientJourneyTemplateForm(request.POST, instance=template)
formset = PatientJourneyStageTemplateFormSet(request.POST, instance=template)
if form.is_valid() and formset.is_valid():
form.save()
formset.save()
messages.success(request, "Journey template updated successfully.")
return redirect('journeys:template_detail', pk=template.pk)
else:
form = PatientJourneyTemplateForm(instance=template)
formset = PatientJourneyStageTemplateFormSet(instance=template)
context = {
'form': form,
'formset': formset,
'template': template,
}
return render(request, 'journeys/template_form.html', context)
@login_required
def journey_template_delete(request, pk):
"""Delete a journey template"""
template = get_object_or_404(PatientJourneyTemplate, 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 delete this template.")
return redirect('journeys:template_list')
if request.method == 'POST':
template_name = template.name
template.delete()
messages.success(request, f"Journey template '{template_name}' deleted successfully.")
return redirect('journeys:template_list')
context = {
'template': template,
}
return render(request, 'journeys/template_confirm_delete.html', context)

View File

@ -22,6 +22,10 @@ urlpatterns = [
path('instances/', ui_views.journey_instance_list, name='instance_list'),
path('instances/<uuid:pk>/', ui_views.journey_instance_detail, name='instance_detail'),
path('templates/', ui_views.journey_template_list, name='template_list'),
path('templates/create/', ui_views.journey_template_create, name='template_create'),
path('templates/<uuid:pk>/', ui_views.journey_template_detail, name='template_detail'),
path('templates/<uuid:pk>/edit/', ui_views.journey_template_edit, name='template_edit'),
path('templates/<uuid:pk>/delete/', ui_views.journey_template_delete, name='template_delete'),
# API Routes
path('', include(router.urls)),

View File

@ -1,67 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='NotificationTemplate',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200, unique=True)),
('description', models.TextField(blank=True)),
('template_type', models.CharField(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')], db_index=True, max_length=50)),
('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_ar', models.TextField(blank=True)),
('email_subject', models.CharField(blank=True, max_length=500)),
('email_subject_ar', models.CharField(blank=True, max_length=500)),
('email_template', models.TextField(blank=True, help_text='Email HTML template with {{variables}}')),
('email_template_ar', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='NotificationLog',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('channel', models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email'), ('push', 'Push Notification')], db_index=True, max_length=20)),
('recipient', models.CharField(help_text='Phone number or email address', max_length=200)),
('subject', models.CharField(blank=True, max_length=500)),
('message', models.TextField()),
('object_id', models.UUIDField(blank=True, null=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('sending', 'Sending'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed'), ('bounced', 'Bounced')], db_index=True, default='pending', max_length=20)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('delivered_at', models.DateTimeField(blank=True, null=True)),
('provider', models.CharField(blank=True, help_text='SMS/Email provider used', max_length=50)),
('provider_message_id', models.CharField(blank=True, help_text='Message ID from provider', max_length=200)),
('provider_response', models.JSONField(blank=True, default=dict, help_text='Full response from provider')),
('error', models.TextField(blank=True)),
('retry_count', models.IntegerField(default=0)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata (campaign, template, etc.)')),
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['channel', 'status', '-created_at'], name='notificatio_channel_b100a4_idx'), models.Index(fields=['recipient', '-created_at'], name='notificatio_recipie_d4670c_idx'), models.Index(fields=['content_type', 'object_id'], name='notificatio_content_bc6e15_idx')],
},
),
]

View File

@ -40,6 +40,16 @@ 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):
return NotificationService.send_sms_via_api(
message=message,
phone=phone,
related_object=related_object,
metadata=metadata
)
# Create notification log
log = NotificationLog.objects.create(
channel='sms',
@ -146,6 +156,18 @@ class NotificationService:
Returns:
NotificationLog instance
"""
# 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(
message=message,
email=email,
subject=subject,
html_message=html_message,
related_object=related_object,
metadata=metadata
)
# Create notification log
log = NotificationLog.objects.create(
channel='email',
@ -182,6 +204,214 @@ class NotificationService:
return log
@staticmethod
def send_email_via_api(message, email, subject, html_message=None, related_object=None, metadata=None):
"""
Send email via external API endpoint with retry logic.
Args:
message: Email message (plain text)
email: Recipient email address
subject: Email subject
html_message: Email message (HTML) (optional)
related_object: Related model instance (optional)
metadata: Additional metadata dict (optional)
Returns:
NotificationLog instance
"""
import requests
import time
# Check if enabled
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',
recipient=email,
subject=subject,
message=message,
content_object=related_object,
provider='api',
metadata={
'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,
}
if 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')
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)
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
)
# API runs in background, accept any 2xx response
if 200 <= response.status_code < 300:
log.mark_sent()
logger.info(f"Email sent via API to {email}: {subject}")
return log
else:
logger.warning(f"API returned status {response.status_code}")
if attempt == max_retries - 1:
log.mark_failed(f"API returned status {response.status_code}")
continue
except requests.exceptions.Timeout:
logger.warning(f"Timeout on attempt {attempt + 1}")
if attempt == max_retries - 1:
log.mark_failed("Request timeout")
except requests.exceptions.ConnectionError:
logger.warning(f"Connection error on attempt {attempt + 1}")
if attempt == max_retries - 1:
log.mark_failed("Connection error")
except Exception as e:
logger.error(f"Unexpected error: {str(e)}")
if attempt == max_retries - 1:
log.mark_failed(str(e))
# Wait before retry (exponential backoff)
if attempt < max_retries - 1:
time.sleep(retry_delay * (2 ** attempt))
return log
@staticmethod
def send_sms_via_api(message, phone, related_object=None, metadata=None):
"""
Send SMS via external API endpoint with retry logic.
Args:
message: SMS message text
phone: Recipient phone number
related_object: Related model instance (optional)
metadata: Additional metadata dict (optional)
Returns:
NotificationLog instance
"""
import requests
import time
# Check if enabled
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',
recipient=phone,
message=message,
content_object=related_object,
provider='api',
metadata={
'api_url': sms_config.get('url'),
'auth_method': sms_config.get('auth_method'),
**(metadata or {})
}
)
# Prepare request payload
payload = {
'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')
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)
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
)
# API runs in background, accept any 2xx response
if 200 <= response.status_code < 300:
log.mark_sent()
logger.info(f"SMS sent via API to {phone}")
return log
else:
logger.warning(f"API returned status {response.status_code}")
if attempt == max_retries - 1:
log.mark_failed(f"API returned status {response.status_code}")
continue
except requests.exceptions.Timeout:
logger.warning(f"Timeout on attempt {attempt + 1}")
if attempt == max_retries - 1:
log.mark_failed("Request timeout")
except requests.exceptions.ConnectionError:
logger.warning(f"Connection error on attempt {attempt + 1}")
if attempt == max_retries - 1:
log.mark_failed("Connection error")
except Exception as e:
logger.error(f"Unexpected error: {str(e)}")
if attempt == max_retries - 1:
log.mark_failed(str(e))
# Wait before retry (exponential backoff)
if attempt < max_retries - 1:
time.sleep(retry_delay * (2 ** attempt))
return log
@staticmethod
def send_notification(recipient, title, message, notification_type='general', related_object=None, metadata=None):
"""
@ -335,3 +565,8 @@ def send_whatsapp(phone, message, **kwargs):
def send_email(email, subject, message, **kwargs):
"""Send Email notification"""
return NotificationService.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)

View File

@ -1,154 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import apps.observations.models
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ObservationCategory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name_en', models.CharField(max_length=200, verbose_name='Name (English)')),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('sort_order', models.IntegerField(default=0, help_text='Lower numbers appear first')),
('icon', models.CharField(blank=True, help_text='Bootstrap icon class', max_length=50)),
],
options={
'verbose_name': 'Observation Category',
'verbose_name_plural': 'Observation Categories',
'ordering': ['sort_order', 'name_en'],
},
),
migrations.CreateModel(
name='Observation',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('tracking_code', models.CharField(db_index=True, default=apps.observations.models.generate_tracking_code, help_text='Unique code for tracking this observation', max_length=20, unique=True)),
('title', models.CharField(blank=True, help_text='Optional short title', max_length=300)),
('description', models.TextField(help_text='Detailed description of the observation')),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
('location_text', models.CharField(blank=True, help_text='Where the issue was observed (building, floor, room, etc.)', max_length=500)),
('incident_datetime', models.DateTimeField(default=django.utils.timezone.now, help_text='When the issue was observed')),
('reporter_staff_id', models.CharField(blank=True, help_text='Optional staff ID of the reporter', max_length=50)),
('reporter_name', models.CharField(blank=True, help_text='Optional name of the reporter', max_length=200)),
('reporter_phone', models.CharField(blank=True, help_text='Optional phone number for follow-up', max_length=20)),
('reporter_email', models.EmailField(blank=True, help_text='Optional email for follow-up', max_length=254)),
('status', models.CharField(choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate')], db_index=True, default='new', max_length=20)),
('source', models.CharField(choices=[('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', max_length=50)),
('triaged_at', models.DateTimeField(blank=True, null=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('resolution_notes', models.TextField(blank=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('action_id', models.UUIDField(blank=True, help_text='ID of linked PX Action if converted', null=True)),
('client_ip', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('assigned_department', models.ForeignKey(blank=True, help_text='Department responsible for handling this observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_observations', to='organizations.department')),
('assigned_to', models.ForeignKey(blank=True, help_text='User assigned to handle this observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_observations', to=settings.AUTH_USER_MODEL)),
('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_observations', to=settings.AUTH_USER_MODEL)),
('hospital', models.ForeignKey(help_text='Hospital where observation was made', on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='organizations.hospital')),
('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_observations', to=settings.AUTH_USER_MODEL)),
('staff', models.ForeignKey(blank=True, help_text='Staff member mentioned in observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.staff')),
('triaged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triaged_observations', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='observations.observationcategory')),
],
options={
'ordering': ['-created_at'],
'permissions': [('triage_observation', 'Can triage observations'), ('manage_categories', 'Can manage observation categories')],
},
),
migrations.CreateModel(
name='ObservationAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(help_text='Uploaded file', upload_to='observations/%Y/%m/%d/')),
('filename', models.CharField(blank=True, max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(default=0, help_text='File size in bytes')),
('description', models.CharField(blank=True, max_length=500)),
('observation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='observations.observation')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ObservationNote',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('note', models.TextField()),
('is_internal', models.BooleanField(default=True, help_text='Internal notes are not visible to public')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observation_notes', to=settings.AUTH_USER_MODEL)),
('observation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='observations.observation')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ObservationStatusLog',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('from_status', models.CharField(blank=True, choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate')], max_length=20)),
('to_status', models.CharField(choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate')], max_length=20)),
('comment', models.TextField(blank=True, help_text='Optional comment about the status change')),
('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observation_status_changes', to=settings.AUTH_USER_MODEL)),
('observation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='status_logs', to='observations.observation')),
],
options={
'verbose_name': 'Observation Status Log',
'verbose_name_plural': 'Observation Status Logs',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='observation',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='observation_hospita_dcd21a_idx'),
),
migrations.AddIndex(
model_name='observation',
index=models.Index(fields=['status', '-created_at'], name='observation_status_2b5566_idx'),
),
migrations.AddIndex(
model_name='observation',
index=models.Index(fields=['severity', '-created_at'], name='observation_severit_ba73c0_idx'),
),
migrations.AddIndex(
model_name='observation',
index=models.Index(fields=['tracking_code'], name='observation_trackin_23f207_idx'),
),
migrations.AddIndex(
model_name='observation',
index=models.Index(fields=['assigned_department', 'status'], name='observation_assigne_33edad_idx'),
),
migrations.AddIndex(
model_name='observation',
index=models.Index(fields=['assigned_to', 'status'], name='observation_assigne_83ab1c_idx'),
),
]

View File

@ -1 +0,0 @@
# Observations migrations

View File

@ -27,7 +27,7 @@ class OrganizationAdmin(admin.ModelAdmin):
@admin.register(Hospital)
class HospitalAdmin(admin.ModelAdmin):
"""Hospital admin"""
list_display = ['name', 'code', 'city', 'status', 'capacity', 'created_at']
list_display = ['name', 'code', 'city', 'ceo', 'status', 'capacity', 'created_at']
list_filter = ['status', 'city']
search_fields = ['name', 'name_ar', 'code', 'license_number']
ordering = ['name']
@ -35,10 +35,11 @@ class HospitalAdmin(admin.ModelAdmin):
fieldsets = (
(None, {'fields': ('organization', 'name', 'name_ar', 'code')}),
('Contact Information', {'fields': ('address', 'city', 'phone', 'email')}),
('Executive Leadership', {'fields': ('ceo', 'medical_director', 'coo', 'cfo')}),
('Details', {'fields': ('license_number', 'capacity', 'status')}),
('Metadata', {'fields': ('created_at', 'updated_at')}),
)
autocomplete_fields = ['organization']
autocomplete_fields = ['organization', 'ceo', 'medical_director', 'coo', 'cfo']
readonly_fields = ['created_at', 'updated_at']
@ -70,18 +71,20 @@ class DepartmentAdmin(admin.ModelAdmin):
@admin.register(Staff)
class StaffAdmin(admin.ModelAdmin):
"""Staff admin"""
list_display = ['__str__', 'staff_type', 'job_title', 'employee_id', 'hospital', 'department', 'has_user_account', 'status']
list_filter = ['status', 'hospital', 'staff_type', 'specialization']
search_fields = ['first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'employee_id', 'license_number', 'job_title']
list_display = ['__str__', 'staff_type', 'job_title', 'employee_id', 'hospital', 'department', 'phone', 'report_to', 'country', 'has_user_account', 'status']
list_filter = ['status', 'hospital', 'staff_type', 'specialization', 'gender', 'country']
search_fields = ['name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'employee_id', 'license_number', 'job_title', 'phone', 'department_name', 'section']
ordering = ['last_name', 'first_name']
autocomplete_fields = ['hospital', 'department', 'user']
autocomplete_fields = ['hospital', 'department', 'user', 'report_to']
actions = ['create_user_accounts', 'send_credentials_emails']
fieldsets = (
(None, {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
(None, {'fields': ('name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
('Role', {'fields': ('staff_type', 'job_title')}),
('Professional', {'fields': ('license_number', 'specialization', 'employee_id', 'email')}),
('Organization', {'fields': ('hospital', 'department')}),
('Professional', {'fields': ('license_number', 'specialization', 'employee_id', 'email', 'phone')}),
('Organization', {'fields': ('hospital', 'department', 'department_name', 'section', 'subsection', 'location')}),
('Hierarchy', {'fields': ('report_to',)}),
('Personal Information', {'fields': ('country', 'gender')}),
('Account', {'fields': ('user',)}),
('Status', {'fields': ('status',)}),
('Metadata', {'fields': ('created_at', 'updated_at')}),
@ -111,12 +114,13 @@ class StaffAdmin(admin.ModelAdmin):
if not staff.user and staff.email:
try:
role = StaffService.get_staff_type_role(staff.staff_type)
user, password = StaffService.create_user_for_staff(
user, was_created, password = StaffService.create_user_for_staff(
staff,
role=role,
request=request
)
StaffService.send_credentials_email(staff, password, request)
if was_created and password:
StaffService.send_credentials_email(staff, password, request)
created += 1
except Exception as e:
failed += 1

View File

@ -0,0 +1,400 @@
"""
Management command to import staff data from CSV file
CSV Format:
Staff ID,Name,Location,Department,Section,Subsection,AlHammadi Job Title,Country,Gender,Manager
Example:
4,ABDULAZIZ SALEH ALHAMMADI,Nuzha,Senior Management Offices,COO Office,,Chief Operating Officer,Saudi Arabia,Male,2 - MOHAMMAD SALEH AL HAMMADI
"""
import csv
import os
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.organizations.models import Hospital, Department, Staff
# Map CSV departments to standard department codes
DEPARTMENT_MAPPING = {
'Senior Management Offices': 'ADM-005',
'Human Resource': 'ADM-005',
'Human Resource ': 'ADM-005', # With trailing space
'Corporate Administration': 'ADM-005',
'Corporate Administration ': 'ADM-005', # With trailing space
'Emergency': 'EMR-001',
'Outpatient': 'OUT-002',
'Inpatient': 'INP-003',
'Diagnostics': 'DIA-004',
'Administration': 'ADM-005',
}
class Command(BaseCommand):
help = 'Import staff data from CSV file'
def add_arguments(self, parser):
parser.add_argument(
'csv_file',
type=str,
help='Path to CSV file to import'
)
parser.add_argument(
'--hospital-code',
type=str,
required=True,
help='Hospital code to assign staff to'
)
parser.add_argument(
'--staff-type',
type=str,
default='admin',
choices=['physician', 'nurse', 'admin', 'other'],
help='Staff type to assign (default: admin)'
)
parser.add_argument(
'--skip-existing',
action='store_true',
help='Skip staff with existing employee_id'
)
parser.add_argument(
'--update-existing',
action='store_true',
help='Update existing staff records'
)
parser.add_argument(
'--create-users',
action='store_true',
help='Create user accounts for imported staff'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview without making changes'
)
def handle(self, *args, **options):
csv_file_path = options['csv_file']
hospital_code = options['hospital_code']
staff_type = options['staff_type']
skip_existing = options['skip_existing']
update_existing = options['update_existing']
create_users = options['create_users']
dry_run = options['dry_run']
self.stdout.write(f"\n{'='*60}")
self.stdout.write("Staff CSV Import Command")
self.stdout.write(f"{'='*60}\n")
# Validate CSV file exists
if not os.path.exists(csv_file_path):
raise CommandError(f"CSV file not found: {csv_file_path}")
# Get hospital
try:
hospital = Hospital.objects.get(code=hospital_code)
self.stdout.write(
self.style.SUCCESS(f"✓ Found hospital: {hospital.name} ({hospital.code})")
)
except Hospital.DoesNotExist:
raise CommandError(f"Hospital with code '{hospital_code}' not found")
# Get departments for this hospital
departments = Department.objects.filter(hospital=hospital, status='active')
self.stdout.write(
self.style.SUCCESS(f"✓ Found {departments.count()} departments in hospital")
)
# Display configuration
self.stdout.write("\nConfiguration:")
self.stdout.write(f" CSV file: {csv_file_path}")
self.stdout.write(f" Hospital: {hospital.name}")
self.stdout.write(f" Staff type: {staff_type}")
self.stdout.write(f" Skip existing: {skip_existing}")
self.stdout.write(f" Update existing: {update_existing}")
self.stdout.write(f" Create user accounts: {create_users}")
self.stdout.write(f" Dry run: {dry_run}")
# Read and parse CSV
self.stdout.write("\nReading CSV file...")
staff_data = self.parse_csv(csv_file_path)
if not staff_data:
self.stdout.write(self.style.WARNING("No valid staff data found in CSV"))
return
self.stdout.write(
self.style.SUCCESS(f"✓ Found {len(staff_data)} staff records in CSV")
)
# Track statistics
stats = {
'created': 0,
'updated': 0,
'skipped': 0,
'errors': 0,
'manager_links': 0
}
# First pass: Create/update all staff records
staff_mapping = {} # Maps employee_id to staff object
with transaction.atomic():
for idx, row in enumerate(staff_data, 1):
try:
# Check if staff already exists
existing_staff = Staff.objects.filter(
employee_id=row['staff_id']
).first()
if existing_staff:
if skip_existing:
self.stdout.write(
f" [{idx}] ⊘ Skipped: {row['name']} (already exists)"
)
stats['skipped'] += 1
continue
if not update_existing:
self.stdout.write(
self.style.ERROR(
f" [{idx}] ✗ Staff already exists: {row['name']} (use --update-existing to update)"
)
)
stats['errors'] += 1
continue
# Update existing staff
self.update_staff(existing_staff, row, hospital, departments, staff_type)
if not dry_run:
existing_staff.save()
self.stdout.write(
self.style.SUCCESS(
f" [{idx}] ✓ Updated: {row['name']}"
)
)
stats['updated'] += 1
staff_mapping[row['staff_id']] = existing_staff
else:
# Create new staff
staff = self.create_staff(row, hospital, departments, staff_type)
if not dry_run:
staff.save()
staff_mapping[row['staff_id']] = staff
self.stdout.write(
self.style.SUCCESS(
f" [{idx}] ✓ Created: {row['name']}"
)
)
stats['created'] += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(
f" [{idx}] ✗ Failed to process {row['name']}: {str(e)}"
)
)
stats['errors'] += 1
# Second pass: Link managers
self.stdout.write("\nLinking manager relationships...")
for idx, row in enumerate(staff_data, 1):
if not row['manager_id']:
continue
try:
staff = staff_mapping.get(row['staff_id'])
if not staff:
continue
manager = staff_mapping.get(row['manager_id'])
if manager:
if staff.report_to != manager:
staff.report_to = manager
if not dry_run:
staff.save()
stats['manager_links'] += 1
self.stdout.write(
self.style.SUCCESS(
f" [{idx}] ✓ Linked {row['name']}{manager.get_full_name()}"
)
)
else:
self.stdout.write(
self.style.WARNING(
f" [{idx}] ⚠ Manager not found: {row['manager_id']} for {row['name']}"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f" [{idx}] ✗ Failed to link manager for {row['name']}: {str(e)}"
)
)
stats['errors'] += 1
# Summary
self.stdout.write("\n" + "="*60)
self.stdout.write("Import Summary:")
self.stdout.write(f" Staff records created: {stats['created']}")
self.stdout.write(f" Staff records updated: {stats['updated']}")
self.stdout.write(f" Staff records skipped: {stats['skipped']}")
self.stdout.write(f" Manager relationships linked: {stats['manager_links']}")
self.stdout.write(f" Errors: {stats['errors']}")
self.stdout.write("="*60 + "\n")
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN: No changes were made\n"))
else:
self.stdout.write(self.style.SUCCESS("Import completed successfully!\n"))
def parse_csv(self, csv_file_path):
"""Parse CSV file and return list of staff data dictionaries"""
staff_data = []
try:
with open(csv_file_path, 'r', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile)
# Expected columns (Phone is optional)
expected_columns = [
'Staff ID', 'Name', 'Location', 'Department',
'Section', 'Subsection', 'AlHammadi Job Title',
'Country', 'Gender', 'Phone', 'Manager'
]
# Validate columns
actual_columns = reader.fieldnames
if not actual_columns:
self.stdout.write(self.style.ERROR("CSV file is empty or has no headers"))
return []
# Normalize column names (remove extra spaces)
normalized_columns = [col.strip() for col in actual_columns]
for row_idx, row in enumerate(reader, 1):
try:
# Parse manager field "ID - Name"
manager_id = None
manager_name = None
if row.get('Manager', '').strip():
manager_parts = row['Manager'].split('-', 1)
manager_id = manager_parts[0].strip()
if len(manager_parts) > 1:
manager_name = manager_parts[1].strip()
# Parse name into first and last name
name = row['Name'].strip()
name_parts = name.split(None, 1) # Split on first space
first_name = name_parts[0] if name_parts else name
last_name = name_parts[1] if len(name_parts) > 1 else ''
# Map department to standard department
dept_name = row['Department'].strip()
dept_code = DEPARTMENT_MAPPING.get(dept_name)
if not dept_code:
# Default to Administration if not found
dept_code = 'ADM-005'
# Phone is optional - check if column exists
phone = ''
if 'Phone' in row:
phone = row['Phone'].strip()
staff_record = {
'staff_id': row['Staff ID'].strip(),
'name': name,
'first_name': first_name,
'last_name': last_name,
'location': row['Location'].strip(),
'department': dept_name,
'department_code': dept_code,
'section': row['Section'].strip(),
'subsection': row['Subsection'].strip(),
'job_title': row['AlHammadi Job Title'].strip(),
'country': row['Country'].strip(),
'gender': row['Gender'].strip().lower(),
'phone': phone,
'manager_id': manager_id,
'manager_name': manager_name
}
staff_data.append(staff_record)
except Exception as e:
self.stdout.write(
self.style.WARNING(f"Skipping row {row_idx}: {str(e)}")
)
continue
except Exception as e:
self.stdout.write(self.style.ERROR(f"Error reading CSV file: {str(e)}"))
return []
return staff_data
def create_staff(self, row, hospital, departments, staff_type):
"""Create a new Staff record from CSV row"""
# Find department
department = None
for dept in departments:
if dept.code == row['department_code']:
department = dept
break
# Create staff record
staff = Staff(
employee_id=row['staff_id'],
name=row['name'], # Store original name from CSV
first_name=row['first_name'],
last_name=row['last_name'],
first_name_ar='',
last_name_ar='',
staff_type=staff_type,
job_title=row['job_title'],
license_number=None,
specialization=row['job_title'], # Use job title as specialization
email='',
phone=row.get('phone', ''), # Phone from CSV (optional)
hospital=hospital,
department=department,
country=row['country'],
location=row['location'], # Store location from CSV
gender=row['gender'],
department_name=row['department'],
section=row['section'],
subsection=row['subsection'],
report_to=None, # Will be linked in second pass
status='active'
)
return staff
def update_staff(self, staff, row, hospital, departments, staff_type):
"""Update existing Staff record from CSV row"""
# Find department
department = None
for dept in departments:
if dept.code == row['department_code']:
department = dept
break
# Update fields
staff.name = row['name'] # Update original name from CSV
staff.first_name = row['first_name']
staff.last_name = row['last_name']
staff.staff_type = staff_type
staff.job_title = row['job_title']
staff.specialization = row['job_title']
staff.phone = row.get('phone', '') # Update phone (optional)
staff.hospital = hospital
staff.department = department
staff.country = row['country']
staff.location = row['location'] # Update location
staff.gender = row['gender']
staff.department_name = row['department']
staff.section = row['section']
staff.subsection = row['subsection']
# report_to will be updated in second pass

View File

@ -0,0 +1,228 @@
"""
Management command to populate existing staff with random emails and phone numbers
"""
import random
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Q
from apps.organizations.models import Staff
class Command(BaseCommand):
help = 'Populate existing staff records with random emails and phone numbers'
def add_arguments(self, parser):
parser.add_argument(
'--hospital-code',
type=str,
help='Target hospital code (default: all hospitals)'
)
parser.add_argument(
'--email-only',
action='store_true',
help='Only populate email addresses'
)
parser.add_argument(
'--phone-only',
action='store_true',
help='Only populate phone numbers'
)
parser.add_argument(
'--overwrite',
action='store_true',
help='Overwrite existing email/phone (default: fill missing only)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview changes without updating database'
)
def handle(self, *args, **options):
hospital_code = options['hospital_code']
email_only = options['email_only']
phone_only = options['phone_only']
overwrite = options['overwrite']
dry_run = options['dry_run']
self.stdout.write(f"\n{'='*60}")
self.stdout.write("Staff Contact Information Populator")
self.stdout.write(f"{'='*60}\n")
# Base queryset
queryset = Staff.objects.all()
# Filter by hospital if specified
if hospital_code:
queryset = queryset.filter(hospital__code__iexact=hospital_code)
self.stdout.write(f"Target hospital: {hospital_code}")
else:
self.stdout.write("Target: All hospitals")
# Filter staff needing updates
if not overwrite:
if email_only:
queryset = queryset.filter(Q(email__isnull=True) | Q(email=''))
elif phone_only:
queryset = queryset.filter(Q(phone__isnull=True) | Q(phone=''))
else:
# Both email and phone
queryset = queryset.filter(
Q(email__isnull=True) | Q(email='') |
Q(phone__isnull=True) | Q(phone='')
)
total_staff = queryset.count()
if total_staff == 0:
self.stdout.write(
self.style.SUCCESS("✓ All staff already have contact information.")
)
return
self.stdout.write(f"\nFound {total_staff} staff to update")
self.stdout.write(f" Email only: {email_only}")
self.stdout.write(f" Phone only: {phone_only}")
self.stdout.write(f" Overwrite existing: {overwrite}")
self.stdout.write(f" Dry run: {dry_run}\n")
# Track statistics
updated_emails = 0
updated_phones = 0
skipped = 0
with transaction.atomic():
for staff in queryset:
update_email = False
update_phone = False
# Determine which fields to update
should_update_email = email_only or (not email_only and not phone_only)
should_update_phone = phone_only or (not email_only and not phone_only)
# Determine if we should update email
if should_update_email:
if overwrite or not staff.email or not staff.email.strip():
if not staff.first_name or not staff.last_name:
self.stdout.write(
self.style.WARNING(f" ⚠ Skipping staff {staff.id}: Missing first/last name")
)
skipped += 1
continue
update_email = True
# Determine if we should update phone
if should_update_phone:
if overwrite or not staff.phone or not staff.phone.strip():
update_phone = True
if not update_email and not update_phone:
skipped += 1
continue
# Generate new values
new_email = None
new_phone = None
if update_email:
new_email = self.generate_email(staff)
updated_emails += 1
if update_phone:
new_phone = self.generate_phone_number()
updated_phones += 1
# Display what will be updated
if dry_run:
updates = []
if new_email:
old_email = staff.email if staff.email else 'None'
updates.append(f"email: {old_email}{new_email}")
if new_phone:
old_phone = staff.phone if staff.phone else 'None'
updates.append(f"phone: {old_phone}{new_phone}")
name = staff.name if staff.name else f"{staff.first_name} {staff.last_name}"
self.stdout.write(f" Would update: {name}")
for update in updates:
self.stdout.write(f" - {update}")
else:
# Apply updates
if new_email:
staff.email = new_email
if new_phone:
staff.phone = new_phone
staff.save()
name = staff.name if staff.name else f"{staff.first_name} {staff.last_name}"
self.stdout.write(f" ✓ Updated: {name}")
if new_email:
self.stdout.write(f" Email: {new_email}")
if new_phone:
self.stdout.write(f" Phone: {new_phone}")
# Summary
self.stdout.write("\n" + "="*60)
self.stdout.write("Summary:")
self.stdout.write(f" Total staff processed: {total_staff}")
self.stdout.write(f" Emails populated: {updated_emails}")
self.stdout.write(f" Phone numbers populated: {updated_phones}")
self.stdout.write(f" Skipped: {skipped}")
self.stdout.write("="*60 + "\n")
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN: No changes were made\n"))
else:
self.stdout.write(self.style.SUCCESS("Contact information populated successfully!\n"))
def generate_email(self, staff):
"""Generate unique email for staff"""
# Use staff.name if available, otherwise use first_name + last_name
if staff.name and staff.name.strip():
# Try to split name into first and last
name_parts = staff.name.strip().split()
if len(name_parts) >= 2:
first_name = name_parts[0]
last_name = name_parts[-1]
else:
first_name = staff.name.strip()
last_name = staff.last_name if staff.last_name else ''
else:
first_name = staff.first_name if staff.first_name else 'user'
last_name = staff.last_name if staff.last_name else 'unknown'
# Clean up names for email (remove spaces and special characters)
clean_first = ''.join(c.lower() for c in first_name if c.isalnum() or c == ' ')
clean_last = ''.join(c.lower() for c in last_name if c.isalnum() or c == ' ')
# Get hospital code for domain
hospital_code = staff.hospital.code if staff.hospital else 'hospital'
hospital_code = hospital_code.lower().replace(' ', '')
base = f"{clean_first.replace(' ', '.')}.{clean_last.replace(' ', '.')}"
email = f"{base}@{hospital_code}.sa"
# Add random suffix if email already exists
counter = 1
while Staff.objects.filter(email=email).exists():
random_num = random.randint(1, 999)
email = f"{base}{random_num}@{hospital_code}.sa"
counter += 1
if counter > 100: # Safety limit
break
return email
def generate_phone_number(self):
"""Generate random Saudi phone number (+966 5X XXX XXXX)"""
# Saudi mobile format: +966 5X XXX XXXX
# X is random digit
second_digit = random.randint(0, 9)
group1 = random.randint(100, 999)
group2 = random.randint(100, 999)
phone = f"+966 5{second_digit} {group1} {group2}"
return phone

View File

@ -0,0 +1,202 @@
"""
Management command to seed standard departments for hospitals
Creates 5 standard departments for hospitals:
1. EMR-001 - Emergency & Urgent Care / الطوارئ والرعاية العاجلة
2. OUT-002 - Outpatient & Specialist Clinics / العيادات الخارجية والعيادات المتخصصة
3. INP-003 - Inpatient & Surgical Services / خدمات العلاج الداخلي والجراحة
4. DIA-004 - Diagnostics & Laboratory Services / خدمات التشخيص والمختبرات
5. ADM-005 - Administration & Support Services / خدمات الإدارة والدعم
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.organizations.models import Hospital, Department
# Standard departments configuration
STANDARD_DEPARTMENTS = [
{
'code': 'EMR-001',
'name': 'Emergency & Urgent Care',
'name_ar': 'الطوارئ والرعاية العاجلة',
'order': 1
},
{
'code': 'OUT-002',
'name': 'Outpatient & Specialist Clinics',
'name_ar': 'العيادات الخارجية والعيادات المتخصصة',
'order': 2
},
{
'code': 'INP-003',
'name': 'Inpatient & Surgical Services',
'name_ar': 'خدمات العلاج الداخلي والجراحة',
'order': 3
},
{
'code': 'DIA-004',
'name': 'Diagnostics & Laboratory Services',
'name_ar': 'خدمات التشخيص والمختبرات',
'order': 4
},
{
'code': 'ADM-005',
'name': 'Administration & Support Services',
'name_ar': 'خدمات الإدارة والدعم',
'order': 5
},
]
class Command(BaseCommand):
help = 'Seed standard departments for hospitals'
def add_arguments(self, parser):
parser.add_argument(
'--hospital-code',
type=str,
help='Target hospital code (default: all hospitals)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview without making changes'
)
parser.add_argument(
'--overwrite',
action='store_true',
help='Overwrite existing departments with same codes'
)
def handle(self, *args, **options):
hospital_code = options['hospital_code']
dry_run = options['dry_run']
overwrite = options['overwrite']
self.stdout.write(f"\n{'='*60}")
self.stdout.write("Standard Departments Seeding Command")
self.stdout.write(f"{'='*60}\n")
# Get hospitals
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"Found {hospitals.count()} hospital(s) to seed departments")
)
# Display configuration
self.stdout.write("\nConfiguration:")
self.stdout.write(f" Departments to create: {len(STANDARD_DEPARTMENTS)}")
self.stdout.write(f" Overwrite existing: {overwrite}")
self.stdout.write(f" Dry run: {dry_run}")
# Display departments
self.stdout.write("\nStandard Departments:")
for dept in STANDARD_DEPARTMENTS:
self.stdout.write(
f" {dept['code']} - {dept['name']} / {dept['name_ar']}"
)
# Track created/skipped departments
stats = {
'created': 0,
'skipped': 0,
'updated': 0,
'errors': 0
}
# Seed departments for each hospital
for hospital in hospitals:
self.stdout.write(f"\nProcessing hospital: {hospital.name} ({hospital.code})")
for dept_config in STANDARD_DEPARTMENTS:
# Check if department already exists
existing_dept = Department.objects.filter(
hospital=hospital,
code=dept_config['code']
).first()
if existing_dept:
if overwrite:
if dry_run:
self.stdout.write(
f" Would update: {dept_config['code']} - {dept_config['name']}"
)
stats['updated'] += 1
else:
# Update existing department
existing_dept.name = dept_config['name']
existing_dept.name_ar = dept_config['name_ar']
existing_dept.save(update_fields=['name', 'name_ar'])
self.stdout.write(
self.style.SUCCESS(
f" ✓ Updated: {dept_config['code']} - {dept_config['name']}"
)
)
stats['updated'] += 1
else:
self.stdout.write(
self.style.WARNING(
f" ⊘ Skipped: {dept_config['code']} already exists"
)
)
stats['skipped'] += 1
else:
# Create new department
if dry_run:
self.stdout.write(
f" Would create: {dept_config['code']} - {dept_config['name']}"
)
stats['created'] += 1
else:
try:
Department.objects.create(
hospital=hospital,
code=dept_config['code'],
name=dept_config['name'],
name_ar=dept_config['name_ar'],
status='active'
)
self.stdout.write(
self.style.SUCCESS(
f" ✓ Created: {dept_config['code']} - {dept_config['name']}"
)
)
stats['created'] += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(
f" ✗ Failed to create {dept_config['code']}: {str(e)}"
)
)
stats['errors'] += 1
# Summary
self.stdout.write("\n" + "="*60)
self.stdout.write("Summary:")
self.stdout.write(f" Hospitals processed: {hospitals.count()}")
self.stdout.write(f" Departments created: {stats['created']}")
self.stdout.write(f" Departments updated: {stats['updated']}")
self.stdout.write(f" Departments skipped: {stats['skipped']}")
self.stdout.write(f" Errors: {stats['errors']}")
self.stdout.write("="*60 + "\n")
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN: No changes were made\n"))
else:
self.stdout.write(self.style.SUCCESS("Department seeding completed successfully!\n"))

View File

@ -298,6 +298,9 @@ class Command(BaseCommand):
# Generate employee ID
employee_id = self.generate_employee_id(hospital.code, staff_type)
# Generate random email
email = self.generate_staff_email(first_name['en'], last_name['en'], hospital.code)
# Generate license number for physicians
license_number = None
if staff_type == Staff.StaffType.PHYSICIAN:
@ -328,6 +331,7 @@ class Command(BaseCommand):
last_name=last_name['en'],
first_name_ar=first_name['ar'],
last_name_ar=last_name['ar'],
email=email,
staff_type=staff_type,
job_title=job_title,
license_number=license_number,
@ -366,20 +370,31 @@ class Command(BaseCommand):
random_num = random.randint(1000000, 9999999)
return f"MOH-LIC-{random_num}"
def generate_staff_email(self, first_name, last_name, hospital_code):
"""Generate unique random email for staff"""
# Clean up names for email (remove spaces and special characters)
clean_first = ''.join(c.lower() for c in first_name if c.isalnum() or c == ' ')
clean_last = ''.join(c.lower() for c in last_name if c.isalnum() or c == ' ')
base = f"{clean_first.replace(' ', '.')}.{clean_last.replace(' ', '.')}"
email = f"{base}@{hospital_code.lower()}.sa"
# Add random suffix if email already exists
counter = 1
while Staff.objects.filter(email=email).exists():
random_num = random.randint(1, 999)
email = f"{base}{random_num}@{hospital_code.lower()}.sa"
counter += 1
if counter > 100: # Safety limit
break
return email
def create_user_for_staff(self, staff, send_email=False):
"""Create a user account for staff using StaffService"""
try:
# Set email on staff profile
email = f"{staff.first_name.lower()}.{staff.last_name.lower()}@{staff.hospital.code.lower()}.sa"
# Check if email exists and generate alternative if needed
if User.objects.filter(email=email).exists():
username = StaffService.generate_username(staff)
email = f"{username}@{staff.hospital.code.lower()}.sa"
# Update staff email
staff.email = email
staff.save(update_fields=['email'])
# Use email that was already set on staff during creation
email = staff.email
# Get role for this staff type
role = StaffService.get_staff_type_role(staff.staff_type)
@ -392,31 +407,29 @@ class Command(BaseCommand):
request = MockRequest()
# Generate password first
password = StaffService.generate_password()
# Create user account using StaffService
user = StaffService.create_user_for_staff(staff, role, request)
user, was_created, password = StaffService.create_user_for_staff(staff, role, request)
# Set the generated password (since StaffService doesn't return it anymore)
user.set_password(password)
user.save()
self.stdout.write(
self.style.SUCCESS(f" ✓ Created user: {user.email} (role: {role})")
)
# Send credential email if requested
if send_email:
try:
StaffService.send_credentials_email(staff, password, request)
self.stdout.write(
self.style.SUCCESS(f" ✓ Sent credential email to: {email}")
)
except Exception as email_error:
self.stdout.write(
self.style.WARNING(f" ⚠ Failed to send email: {str(email_error)}")
)
if was_created:
self.stdout.write(
self.style.SUCCESS(f" ✓ Created user: {user.email} (role: {role})")
)
# Send credential email if requested
if send_email:
try:
StaffService.send_credentials_email(staff, password, request)
self.stdout.write(
self.style.SUCCESS(f" ✓ Sent credential email to: {email}")
)
except Exception as email_error:
self.stdout.write(
self.style.WARNING(f" ⚠ Failed to send email: {str(email_error)}")
)
else:
self.stdout.write(
self.style.SUCCESS(f" ✓ Linked existing user: {user.email} (role: {role})")
)
except Exception as e:
self.stdout.write(

View File

@ -1,142 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Hospital',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('code', models.CharField(db_index=True, max_length=50, unique=True)),
('address', models.TextField(blank=True)),
('city', models.CharField(blank=True, max_length=100)),
('phone', models.CharField(blank=True, max_length=20)),
('email', models.EmailField(blank=True, max_length=254)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('license_number', models.CharField(blank=True, max_length=100)),
('capacity', models.IntegerField(blank=True, help_text='Bed capacity', null=True)),
('metadata', models.JSONField(blank=True, default=dict)),
],
options={
'verbose_name_plural': 'Hospitals',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Organization',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('code', models.CharField(db_index=True, max_length=50, unique=True)),
('phone', models.CharField(blank=True, max_length=20)),
('email', models.EmailField(blank=True, max_length=254)),
('address', models.TextField(blank=True)),
('city', models.CharField(blank=True, max_length=100)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('logo', models.ImageField(blank=True, null=True, upload_to='organizations/logos/')),
('website', models.URLField(blank=True)),
('license_number', models.CharField(blank=True, max_length=100)),
],
options={
'verbose_name': 'Organization',
'verbose_name_plural': 'Organizations',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Department',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('code', models.CharField(db_index=True, max_length=50)),
('phone', models.CharField(blank=True, max_length=20)),
('email', models.EmailField(blank=True, max_length=254)),
('location', models.CharField(blank=True, help_text='Building/Floor/Room', max_length=200)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_departments', to=settings.AUTH_USER_MODEL)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sub_departments', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='departments', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'name'],
'unique_together': {('hospital', 'code')},
},
),
migrations.AddField(
model_name='hospital',
name='organization',
field=models.ForeignKey(blank=True, help_text='Parent organization (null for backward compatibility)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hospitals', to='organizations.organization'),
),
migrations.CreateModel(
name='Patient',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('mrn', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='Medical Record Number')),
('national_id', models.CharField(blank=True, db_index=True, max_length=50)),
('first_name', models.CharField(max_length=100)),
('last_name', models.CharField(max_length=100)),
('first_name_ar', models.CharField(blank=True, max_length=100)),
('last_name_ar', models.CharField(blank=True, max_length=100)),
('date_of_birth', models.DateField(blank=True, null=True)),
('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], max_length=10)),
('phone', models.CharField(blank=True, max_length=20)),
('email', models.EmailField(blank=True, max_length=254)),
('address', models.TextField(blank=True)),
('city', models.CharField(blank=True, max_length=100)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('primary_hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='patients', to='organizations.hospital')),
],
options={
'ordering': ['last_name', 'first_name'],
},
),
migrations.CreateModel(
name='Staff',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('first_name', models.CharField(max_length=100)),
('last_name', models.CharField(max_length=100)),
('first_name_ar', models.CharField(blank=True, max_length=100)),
('last_name_ar', models.CharField(blank=True, max_length=100)),
('staff_type', models.CharField(choices=[('physician', 'Physician'), ('nurse', 'Nurse'), ('admin', 'Administrative'), ('other', 'Other')], max_length=20)),
('job_title', models.CharField(max_length=200)),
('license_number', models.CharField(blank=True, max_length=100, null=True, unique=True)),
('specialization', models.CharField(blank=True, max_length=200)),
('email', models.EmailField(blank=True, max_length=254)),
('employee_id', models.CharField(db_index=True, max_length=50, unique=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='active', max_length=20)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff', to='organizations.hospital')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -68,13 +68,49 @@ class Hospital(UUIDModel, TimeStampedModel):
db_index=True
)
# Executive leadership
ceo = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='hospitals_as_ceo',
verbose_name='CEO',
help_text="Chief Executive Officer"
)
medical_director = models.ForeignKey(
'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"
)
coo = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='hospitals_as_coo',
verbose_name='COO',
help_text="Chief Operating Officer"
)
cfo = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='hospitals_as_cfo',
verbose_name='CFO',
help_text="Chief Financial Officer"
)
# Metadata
license_number = models.CharField(max_length=100, blank=True)
capacity = models.IntegerField(null=True, blank=True, help_text="Bed capacity")
metadata = models.JSONField(default=dict, blank=True, help_text="Hospital configuration settings")
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['name']
verbose_name_plural = 'Hospitals'
@ -158,18 +194,62 @@ class Staff(UUIDModel, TimeStampedModel):
license_number = models.CharField(max_length=100, unique=True, null=True, blank=True)
specialization = models.CharField(max_length=200, blank=True)
email = models.EmailField(blank=True)
phone = models.CharField(max_length=20, blank=True, verbose_name="Phone Number")
employee_id = models.CharField(max_length=50, unique=True, db_index=True)
# Original name from CSV (preserves exact format)
name = models.CharField(max_length=300, blank=True, verbose_name="Full Name (Original)")
# 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')
# Additional fields from CSV import
country = models.CharField(max_length=100, blank=True, verbose_name="Country")
location = models.CharField(max_length=200, blank=True, verbose_name="Location")
gender = models.CharField(
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)")
section = models.CharField(max_length=200, blank=True, verbose_name="Section")
subsection = models.CharField(max_length=200, blank=True, verbose_name="Subsection")
# Self-referential manager field for hierarchy
report_to = models.ForeignKey(
'self',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='direct_reports',
verbose_name="Reports To"
)
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE)
def __str__(self):
# Use original name if available, otherwise use first_name + last_name
if self.name:
return self.name
prefix = "Dr. " if self.staff_type == self.StaffType.PHYSICIAN else ""
return f"{prefix}{self.first_name} {self.last_name}"
def get_full_name(self):
"""Get full name including Arabic if available"""
if self.first_name_ar and self.last_name_ar:
return f"{self.first_name} {self.last_name} ({self.first_name_ar} {self.last_name_ar})"
return f"{self.first_name} {self.last_name}"
def get_org_info(self):
"""Get organization and department information"""
parts = [self.hospital.name]
if self.department:
parts.append(self.department.name)
if self.department_name:
parts.append(self.department_name)
return " - ".join(parts)
# TODO Add Section
# class Physician(UUIDModel, TimeStampedModel):
# """Physician/Doctor model"""

View File

@ -70,11 +70,15 @@ class DepartmentSerializer(serializers.ModelSerializer):
class StaffSerializer(serializers.ModelSerializer):
"""Staff serializer"""
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
department_name = serializers.CharField(source='department.name', read_only=True)
department_name_display = serializers.CharField(source='department.name', read_only=True)
department_name = serializers.CharField(read_only=True)
full_name = serializers.CharField(source='get_full_name', read_only=True)
org_info = serializers.CharField(source='get_org_info', read_only=True)
user_email = serializers.EmailField(source='user.email', read_only=True, allow_null=True)
has_user_account = serializers.BooleanField(read_only=True)
report_to_name = serializers.SerializerMethodField()
direct_reports_count = serializers.SerializerMethodField()
# User creation fields (write-only)
create_user = serializers.BooleanField(write_only=True, required=False, default=False)
user_username = serializers.CharField(write_only=True, required=False, allow_blank=True)
@ -84,15 +88,28 @@ class StaffSerializer(serializers.ModelSerializer):
class Meta:
model = Staff
fields = [
'id', 'user', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
'full_name', 'staff_type', 'job_title',
'id', 'user', 'name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
'full_name', 'org_info', 'staff_type', 'job_title',
'license_number', 'specialization', 'employee_id',
'hospital', 'hospital_name', 'department', 'department_name',
'email', 'phone',
'hospital', 'hospital_name', 'department', 'department_name', 'department_name_display',
'location', 'section', 'subsection', 'country', 'gender',
'report_to', 'report_to_name', 'direct_reports_count',
'user_email', 'has_user_account', 'status',
'created_at', 'updated_at',
'create_user', 'user_username', 'user_password', 'send_email'
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_report_to_name(self, obj):
"""Get manager (report_to) full name"""
if obj.report_to:
return obj.report_to.get_full_name()
return None
def get_direct_reports_count(self, obj):
"""Get count of direct reports"""
return obj.direct_reports.count()
def to_representation(self, instance):
"""Customize representation"""
@ -120,14 +137,14 @@ class StaffSerializer(serializers.ModelSerializer):
# Create user account
try:
user, password = StaffService.create_user_for_staff(
user, was_created, password = StaffService.create_user_for_staff(
staff,
role=role,
request=self.context.get('request')
)
# Send email if requested
if send_email and self.context.get('request'):
# Send email if requested and user was created
if was_created and password and send_email and self.context.get('request'):
try:
StaffService.send_credentials_email(
staff,
@ -165,14 +182,14 @@ class StaffSerializer(serializers.ModelSerializer):
role = StaffService.get_staff_type_role(instance.staff_type)
try:
user, password = StaffService.create_user_for_staff(
user, was_created, password = StaffService.create_user_for_staff(
instance,
role=role,
request=self.context.get('request')
)
# Send email if requested
if send_email and self.context.get('request'):
# Send email if requested and user was created
if was_created and password and send_email and self.context.get('request'):
try:
StaffService.send_credentials_email(
instance,

View File

@ -49,6 +49,7 @@ class StaffService:
def create_user_for_staff(staff, role='staff', request=None):
"""
Create a User account for a Staff member.
If a user with the same email already exists, link it to the staff member instead.
Args:
staff: Staff instance
@ -56,10 +57,13 @@ class StaffService:
request: HTTP request for audit logging
Returns:
User: Created user instance
tuple: (User instance, was_created: bool, password: str or None)
- was_created is True if a new user was created
- was_created is False if an existing user was linked
- password is the generated password for new users, None for linked users
Raises:
ValueError: If staff already has a user account
ValueError: If staff already has a user account or has no email
"""
if staff.user:
raise ValueError("Staff member already has a user account")
@ -68,11 +72,56 @@ class StaffService:
if not staff.email:
raise ValueError("Staff member must have an email address")
# Check if user with this email already exists
existing_user = User.objects.filter(email=staff.email).first()
if existing_user:
# Link existing user to staff
staff.user = existing_user
staff.save(update_fields=['user'])
# Update user's organization data if not set
if not existing_user.hospital:
existing_user.hospital = staff.hospital
if not existing_user.department:
existing_user.department = staff.department
if not existing_user.employee_id:
existing_user.employee_id = staff.employee_id
existing_user.save(update_fields=['hospital', 'department', 'employee_id'])
# Assign role if not already assigned
from apps.accounts.models import Role as RoleModel
try:
role_obj = RoleModel.objects.get(name=role)
if not existing_user.groups.filter(id=role_obj.group.id).exists():
existing_user.groups.add(role_obj.group)
except RoleModel.DoesNotExist:
pass
# Log the action
if request:
AuditService.log_from_request(
event_type='other',
description=f"Existing user account linked to staff member {staff.get_full_name()}",
request=request,
content_object=existing_user,
metadata={
'staff_id': str(staff.id),
'staff_name': staff.get_full_name(),
'user_id': str(existing_user.id),
'action': 'linked_existing_user'
}
)
return existing_user, False, None # Existing user was linked, no password
# Create new user account
# Generate username (optional, for backward compatibility)
username = StaffService.generate_username(staff)
password = StaffService.generate_password()
# Create user - email is now the username field
# Note: create_user() already hashes the password, so no need to call set_password() separately
user = User.objects.create_user(
email=staff.email,
password=password,
@ -87,7 +136,7 @@ class StaffService:
)
# Assign role
from .models import Role as RoleModel
from apps.accounts.models import Role as RoleModel
try:
role_obj = RoleModel.objects.get(name=role)
user.groups.add(role_obj.group)
@ -108,11 +157,12 @@ class StaffService:
metadata={
'staff_id': str(staff.id),
'staff_name': staff.get_full_name(),
'role': role
'role': role,
'action': 'created_new_user'
}
)
return user
return user, True, password # New user was created with password
@staticmethod
def link_user_to_staff(staff, user_id, request=None):

View File

@ -373,20 +373,19 @@ def staff_create(request):
from .services import StaffService
try:
role = StaffService.get_staff_type_role(staff.staff_type)
user_account = StaffService.create_user_for_staff(
user_account, was_created, password = StaffService.create_user_for_staff(
staff,
role=role,
request=request
)
# Generate password for email
password = StaffService.generate_password()
user_account.set_password(password)
user_account.save()
try:
StaffService.send_credentials_email(staff, password, request)
messages.success(request, 'Staff member created and credentials email sent successfully.')
except Exception as e:
messages.warning(request, f'Staff member created but email sending failed: {str(e)}')
if was_created and password:
try:
StaffService.send_credentials_email(staff, password, request)
messages.success(request, 'Staff member created and credentials email sent successfully.')
except Exception as e:
messages.warning(request, f'Staff member created but email sending failed: {str(e)}')
elif not was_created:
messages.success(request, 'Existing user account linked successfully.')
except Exception as e:
messages.error(request, f'Staff member created but user account creation failed: {str(e)}')
@ -442,20 +441,19 @@ def staff_update(request, pk):
from .services import StaffService
try:
role = StaffService.get_staff_type_role(staff.staff_type)
user_account = StaffService.create_user_for_staff(
user_account, was_created, password = StaffService.create_user_for_staff(
staff,
role=role,
request=request
)
# Generate password for email
password = StaffService.generate_password()
user_account.set_password(password)
user_account.save()
try:
StaffService.send_credentials_email(staff, password, request)
messages.success(request, 'User account created and credentials email sent.')
except Exception as e:
messages.warning(request, f'User account created but email sending failed: {str(e)}')
if was_created and password:
try:
StaffService.send_credentials_email(staff, password, request)
messages.success(request, 'User account created and credentials email sent.')
except Exception as e:
messages.warning(request, f'User account created but email sending failed: {str(e)}')
elif not was_created:
messages.success(request, 'Existing user account linked successfully.')
except Exception as e:
messages.error(request, f'User account creation failed: {str(e)}')
@ -472,3 +470,149 @@ def staff_update(request, pk):
}
return render(request, 'organizations/staff_form.html', context)
@login_required
def staff_hierarchy(request):
"""
Staff hierarchy tree view
Shows organizational structure based on report_to relationships
"""
queryset = Staff.objects.select_related('hospital', 'department', 'report_to')
# Apply RBAC filters
user = request.user
if not user.is_px_admin() and user.hospital:
queryset = queryset.filter(hospital=user.hospital)
# Apply filters
hospital_filter = request.GET.get('hospital')
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
department_filter = request.GET.get('department')
if department_filter:
queryset = queryset.filter(department_id=department_filter)
# Search functionality
search_query = request.GET.get('search')
search_result = None
if search_query:
try:
search_result = Staff.objects.get(
Q(employee_id__iexact=search_query) |
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query)
)
# If search result exists and user has access, start hierarchy from that staff
if search_result and (user.is_px_admin() or search_result.hospital == user.hospital):
queryset = Staff.objects.filter(
Q(id=search_result.id) |
Q(hospital=search_result.hospital)
)
except Staff.DoesNotExist:
pass
# Build hierarchy structure
def build_hierarchy(staff_list, parent=None, level=0):
"""Recursively build hierarchy tree"""
result = []
for staff in staff_list:
if staff.report_to == parent:
node = {
'staff': staff,
'level': level,
'direct_reports': build_hierarchy(staff_list, staff, level + 1),
'has_children': bool(staff.direct_reports.exists())
}
result.append(node)
return result
# Get all staff for the current filter
all_staff = list(queryset)
# If searching, build hierarchy from search result up
if search_result:
# Get all managers up the chain
manager_chain = []
current = search_result.report_to
while current:
if current in all_staff:
manager_chain.insert(0, current)
current = current.report_to
# Add search result to chain
if search_result not in manager_chain:
manager_chain.append(search_result)
# Build hierarchy for managers and their reports
hierarchy = build_hierarchy(all_staff, parent=None)
# Find and highlight search result
def find_and_mark(node, target_id, path=None):
if path is None:
path = []
if node['staff'].id == target_id:
node['is_search_result'] = True
node['search_path'] = path + [node['staff'].id]
return node
for child in node['direct_reports']:
result = find_and_mark(child, target_id, path + [node['staff'].id])
if result:
return result
return None
search_result_node = None
for root in hierarchy:
result = find_and_mark(root, search_result.id)
if result:
search_result_node = result
break
else:
# Build hierarchy starting from top-level (no report_to)
hierarchy = build_hierarchy(all_staff, parent=None)
# 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 departments for filter
departments = Department.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital)
# Calculate statistics
total_staff = queryset.count()
top_managers = len(hierarchy)
context = {
'hierarchy': hierarchy,
'hospitals': hospitals,
'departments': departments,
'filters': request.GET,
'total_staff': total_staff,
'top_managers': top_managers,
'search_result': search_result,
}
return render(request, 'organizations/staff_hierarchy.html', context)
@login_required
def staff_hierarchy_d3(request):
"""
Staff hierarchy D3 visualization view
Shows interactive organizational chart using D3.js
"""
# Get hospitals for filter (used by client-side filters)
hospitals = Hospital.objects.filter(status='active')
user = request.user
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
context = {
'hospitals': hospitals,
}
return render(request, 'organizations/staff_hierarchy_d3.html', context)

View File

@ -30,6 +30,8 @@ urlpatterns = [
path('staff/create/', ui_views.staff_create, name='staff_create'),
path('staff/<uuid:pk>/', ui_views.staff_detail, name='staff_detail'),
path('staff/<uuid:pk>/edit/', ui_views.staff_update, name='staff_update'),
path('staff/hierarchy/', ui_views.staff_hierarchy, name='staff_hierarchy'),
path('staff/hierarchy/d3/', ui_views.staff_hierarchy_d3, name='staff_hierarchy_d3'),
path('patients/', ui_views.patient_list, name='patient_list'),
# API Routes

View File

@ -225,30 +225,30 @@ class StaffViewSet(viewsets.ModelViewSet):
role = request.data.get('role', StaffService.get_staff_type_role(staff.staff_type))
try:
user_account = StaffService.create_user_for_staff(
user_account, was_created, password = StaffService.create_user_for_staff(
staff,
role=role,
request=request
)
# Generate password for email
password = StaffService.generate_password()
user_account.set_password(password)
user_account.save()
# Send email
try:
StaffService.send_credentials_email(staff, password, request)
message = 'User account created and credentials emailed successfully'
except Exception as e:
message = f'User account created. Email sending failed: {str(e)}'
if was_created:
# Send email with credentials (password is already set in create_user_for_staff)
try:
StaffService.send_credentials_email(staff, password, request)
message = 'User account created and credentials emailed successfully'
except Exception as e:
message = f'User account created. Email sending failed: {str(e)}'
else:
# Existing user was linked - no password to generate or email to send
message = 'Existing user account linked successfully. The staff member can now log in with their existing credentials.'
serializer = self.get_serializer(staff)
return Response({
'message': message,
'staff': serializer.data,
'email': user_account.email
}, status=status.HTTP_201_CREATED)
'email': user_account.email,
'was_created': was_created
}, status=status.HTTP_200_OK if not was_created else status.HTTP_201_CREATED)
except ValueError as e:
return Response(
@ -402,6 +402,149 @@ class StaffViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'])
def hierarchy(self, request):
"""
Get staff hierarchy as D3-compatible JSON.
Used for interactive tree visualization.
Note: This action uses a more permissive queryset to allow all authenticated
users to view the organization hierarchy for visualization purposes.
"""
from django.db.models import Q
# Get filter parameters
hospital_id = request.query_params.get('hospital')
department_id = request.query_params.get('department')
search = request.query_params.get('search', '').strip()
# Build base queryset - use all staff for hierarchy visualization
# This allows any authenticated user to see the full organizational structure
queryset = StaffModel.objects.all().select_related('report_to', 'hospital', 'department')
# Apply filters
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
if department_id:
queryset = queryset.filter(department_id=department_id)
if search:
queryset = queryset.filter(
Q(first_name__icontains=search) |
Q(last_name__icontains=search) |
Q(employee_id__icontains=search)
)
# Get all staff with their managers
staff_list = queryset.select_related('report_to', 'hospital', 'department')
# Build staff lookup dictionary
staff_dict = {staff.id: staff for staff in staff_list}
# Build hierarchy tree
def build_node(staff):
"""Recursively build hierarchy node for D3"""
node = {
'id': staff.id,
'name': staff.get_full_name(),
'employee_id': staff.employee_id,
'job_title': staff.job_title or '',
'hospital': staff.hospital.name if staff.hospital else '',
'department': staff.department.name if staff.department else '',
'status': staff.status,
'staff_type': staff.staff_type,
'team_size': 0, # Will be calculated
'children': []
}
# Find direct reports
direct_reports = [
s for s in staff_list
if s.report_to_id == staff.id
]
# Recursively build children
for report in direct_reports:
child_node = build_node(report)
node['children'].append(child_node)
node['team_size'] += 1 + child_node['team_size']
return node
# Group root nodes by organization
from collections import defaultdict
org_groups = defaultdict(list)
# Find root nodes (staff with no manager in the filtered set)
root_staff = [
staff for staff in staff_list
if staff.report_to_id is None or staff.report_to_id not in staff_dict
]
# Group root staff by organization
for staff in root_staff:
if staff.hospital and staff.hospital.organization:
org_name = staff.hospital.organization.name
else:
org_name = 'Organization'
org_groups[org_name].append(staff)
# Build hierarchy for each organization
hierarchy = []
top_managers = 0
for org_name, org_root_staff in org_groups.items():
# Build hierarchy nodes for this organization's root staff
org_root_nodes = [build_node(staff) for staff in org_root_staff]
# Calculate total team size for this organization
org_team_size = sum(node['team_size'] + 1 for node in org_root_nodes)
# Create organization node as parent
org_node = {
'id': None,
'name': org_name,
'employee_id': '',
'job_title': 'Organization',
'hospital': '',
'department': '',
'status': 'active',
'staff_type': 'organization',
'team_size': org_team_size,
'children': org_root_nodes,
'is_organization_root': True
}
hierarchy.append(org_node)
top_managers += len(org_root_nodes)
# If there are multiple organizations, wrap them in a single root
if len(hierarchy) > 1:
total_team_size = sum(node['team_size'] for node in hierarchy)
hierarchy = [{
'id': None,
'name': 'All Organizations',
'employee_id': '',
'job_title': '',
'hospital': '',
'department': '',
'status': 'active',
'staff_type': 'root',
'team_size': total_team_size,
'children': hierarchy,
'is_virtual_root': True
}]
# Calculate statistics
total_staff = len(staff_list)
return Response({
'hierarchy': hierarchy,
'statistics': {
'total_staff': total_staff,
'top_managers': top_managers
}
})
class PatientViewSet(viewsets.ModelViewSet):
"""

View File

@ -1,42 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PhysicianMonthlyRating',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('year', models.IntegerField(db_index=True)),
('month', models.IntegerField(db_index=True, help_text='1-12')),
('average_rating', models.DecimalField(decimal_places=2, help_text='Average rating (1-5)', max_digits=3)),
('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)),
('md_consult_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True)),
('hospital_rank', models.IntegerField(blank=True, help_text='Rank within hospital', null=True)),
('department_rank', models.IntegerField(blank=True, help_text='Rank within department', null=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_ratings', to='organizations.staff')),
],
options={
'ordering': ['-year', '-month', '-average_rating'],
'indexes': [models.Index(fields=['staff', '-year', '-month'], name='physicians__staff_i_f4cc8b_idx'), models.Index(fields=['year', 'month', '-average_rating'], name='physicians__year_e38883_idx')],
'unique_together': {('staff', 'year', 'month')},
},
),
]

View File

@ -1,60 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='QIProjectTask',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=500)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('due_date', models.DateField(blank=True, null=True)),
('completed_date', models.DateField(blank=True, null=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['project', 'order'],
},
),
migrations.CreateModel(
name='QIProject',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('description', models.TextField()),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20)),
('start_date', models.DateField(blank=True, null=True)),
('target_completion_date', models.DateField(blank=True, db_index=True, null=True)),
('actual_completion_date', models.DateField(blank=True, null=True)),
('outcome_description', models.TextField(blank=True)),
('success_metrics', models.JSONField(blank=True, default=dict, help_text='Success metrics and results')),
('metadata', models.JSONField(blank=True, default=dict)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_projects', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='qi_projects', to='organizations.hospital')),
('project_lead', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_projects', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@ -1,43 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('projects', '0001_initial'),
('px_action_center', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='qiproject',
name='related_actions',
field=models.ManyToManyField(blank=True, related_name='qi_projects', to='px_action_center.pxaction'),
),
migrations.AddField(
model_name='qiproject',
name='team_members',
field=models.ManyToManyField(blank=True, related_name='qi_projects', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='qiprojecttask',
name='assigned_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_tasks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='qiprojecttask',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.qiproject'),
),
migrations.AddIndex(
model_name='qiproject',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='projects_qi_hospita_e5dfc7_idx'),
),
]

View File

@ -1,163 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PXAction',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('source_type', models.CharField(choices=[('survey', 'Negative Survey'), ('complaint', 'Complaint'), ('complaint_resolution', 'Negative Complaint Resolution'), ('social_media', 'Social Media'), ('call_center', 'Call Center'), ('kpi', 'KPI Decline'), ('manual', 'Manual')], db_index=True, max_length=50)),
('object_id', models.UUIDField(blank=True, null=True)),
('title', models.CharField(max_length=500)),
('description', models.TextField()),
('category', models.CharField(choices=[('clinical_quality', 'Clinical Quality'), ('patient_safety', 'Patient Safety'), ('service_quality', 'Service Quality'), ('staff_behavior', 'Staff Behavior'), ('facility', 'Facility & Environment'), ('process_improvement', 'Process Improvement'), ('other', 'Other')], db_index=True, max_length=100)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('pending_approval', 'Pending Approval'), ('approved', 'Approved'), ('closed', 'Closed'), ('cancelled', 'Cancelled')], db_index=True, default='open', max_length=20)),
('assigned_at', models.DateTimeField(blank=True, null=True)),
('due_at', models.DateTimeField(db_index=True, help_text='SLA deadline')),
('is_overdue', models.BooleanField(db_index=True, default=False)),
('reminder_sent_at', models.DateTimeField(blank=True, null=True)),
('escalated_at', models.DateTimeField(blank=True, null=True)),
('escalation_level', models.IntegerField(default=0, help_text='Number of times escalated')),
('requires_approval', models.BooleanField(default=True, help_text='Requires PX Admin approval before closure')),
('approved_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('action_plan', models.TextField(blank=True)),
('outcome', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_actions', to=settings.AUTH_USER_MODEL)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_actions', to=settings.AUTH_USER_MODEL)),
('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_actions', to=settings.AUTH_USER_MODEL)),
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='px_actions', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='px_actions', to='organizations.hospital')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='PXActionAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='actions/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
('is_evidence', models.BooleanField(default=False, help_text='Mark as evidence for closure')),
('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='px_action_center.pxaction')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='action_attachments', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='PXActionLog',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('log_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('escalation', 'Escalation'), ('note', 'Note'), ('evidence', 'Evidence Added'), ('approval', 'Approval'), ('sla_reminder', 'SLA Reminder')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('metadata', models.JSONField(blank=True, default=dict)),
('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='px_action_center.pxaction')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='action_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='PXActionSLAConfig',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('critical_hours', models.IntegerField(default=24)),
('high_hours', models.IntegerField(default=48)),
('medium_hours', models.IntegerField(default=72)),
('low_hours', models.IntegerField(default=120)),
('reminder_hours_before', models.IntegerField(default=4, help_text='Send reminder X hours before due')),
('auto_escalate', models.BooleanField(default=True, help_text='Automatically escalate when overdue')),
('escalation_delay_hours', models.IntegerField(default=2, help_text='Hours after overdue before escalation')),
('is_active', models.BooleanField(default=True)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='action_sla_configs', to='organizations.department')),
('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='action_sla_configs', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'name'],
},
),
migrations.CreateModel(
name='RoutingRule',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('source_type', models.CharField(blank=True, choices=[('survey', 'Negative Survey'), ('complaint', 'Complaint'), ('complaint_resolution', 'Negative Complaint Resolution'), ('social_media', 'Social Media'), ('call_center', 'Call Center'), ('kpi', 'KPI Decline'), ('manual', 'Manual')], max_length=50)),
('category', models.CharField(blank=True, max_length=100)),
('severity', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], max_length=20)),
('assign_to_role', models.CharField(blank=True, help_text="Role to assign to (e.g., 'PX Coordinator')", max_length=50)),
('priority', models.IntegerField(default=0, help_text='Higher priority rules are evaluated first')),
('is_active', models.BooleanField(default=True)),
('assign_to_department', models.ForeignKey(blank=True, help_text='Department to assign to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='routing_target_rules', to='organizations.department')),
('assign_to_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='routing_rules', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='routing_rules', to='organizations.department')),
('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='routing_rules', to='organizations.hospital')),
],
options={
'ordering': ['-priority', 'name'],
},
),
migrations.AddIndex(
model_name='pxaction',
index=models.Index(fields=['status', '-created_at'], name='px_action_c_status_3bd857_idx'),
),
migrations.AddIndex(
model_name='pxaction',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='px_action_c_hospita_6b2d44_idx'),
),
migrations.AddIndex(
model_name='pxaction',
index=models.Index(fields=['is_overdue', 'status'], name='px_action_c_is_over_e0d12c_idx'),
),
migrations.AddIndex(
model_name='pxaction',
index=models.Index(fields=['due_at', 'status'], name='px_action_c_due_at_947f38_idx'),
),
migrations.AddIndex(
model_name='pxaction',
index=models.Index(fields=['source_type', '-created_at'], name='px_action_c_source__3f0ae5_idx'),
),
migrations.AddIndex(
model_name='pxactionlog',
index=models.Index(fields=['action', '-created_at'], name='px_action_c_action__656e57_idx'),
),
]

View File

@ -1,78 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PXSource',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name_en', models.CharField(help_text='Source name in English', max_length=200)),
('name_ar', models.CharField(blank=True, help_text='Source name in Arabic', max_length=200)),
('description', models.TextField(blank=True, help_text='Detailed description')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this source is active for selection')),
],
options={
'verbose_name': 'PX Source',
'verbose_name_plural': 'PX Sources',
'ordering': ['name_en'],
'indexes': [models.Index(fields=['is_active', 'name_en'], name='px_sources__is_acti_ea1b54_idx')],
},
),
migrations.CreateModel(
name='SourceUsage',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('object_id', models.UUIDField(help_text='ID of related object')),
('content_type', models.ForeignKey(help_text='Type of related object', on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('hospital', models.ForeignKey(blank=True, help_text='Hospital where this source was used', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_usage_records', to='organizations.hospital')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='usage_records', to='px_sources.pxsource')),
('user', models.ForeignKey(blank=True, help_text='User who selected this source', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_usage_records', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Source Usage',
'verbose_name_plural': 'Source Usages',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['source', '-created_at'], name='px_sources__source__13a9ae_idx'), models.Index(fields=['content_type', 'object_id'], name='px_sources__content_30cb33_idx'), models.Index(fields=['hospital', '-created_at'], name='px_sources__hospita_a0479a_idx'), models.Index(fields=['created_at'], name='px_sources__created_8606b0_idx')],
'unique_together': {('content_type', 'object_id')},
},
),
migrations.CreateModel(
name='SourceUser',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this source user is active')),
('can_create_complaints', models.BooleanField(default=True, help_text='User can create complaints from this source')),
('can_create_inquiries', models.BooleanField(default=True, help_text='User can create inquiries from this source')),
('source', models.ForeignKey(help_text='Source managed by this user', on_delete=django.db.models.deletion.CASCADE, related_name='source_users', to='px_sources.pxsource')),
('user', models.OneToOneField(help_text='User who manages this source', on_delete=django.db.models.deletion.CASCADE, related_name='source_user_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Source User',
'verbose_name_plural': 'Source Users',
'ordering': ['source__name_en'],
'indexes': [models.Index(fields=['user', 'is_active'], name='px_sources__user_id_40a726_idx'), models.Index(fields=['source', 'is_active'], name='px_sources__source__eb51c5_idx')],
'unique_together': {('user', 'source')},
},
),
]

View File

@ -1 +0,0 @@
# PX Sources migrations

View File

@ -1,121 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import apps.references.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ReferenceFolder',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_deleted', models.BooleanField(db_index=True, default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('name', models.CharField(db_index=True, max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('description', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')),
('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')", max_length=50)),
('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#007bff')", max_length=7)),
('order', models.IntegerField(default=0, help_text='Display order within parent folder')),
('is_active', models.BooleanField(db_index=True, default=True)),
('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this folder (empty = all roles)', related_name='accessible_folders', to='auth.group')),
('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')),
('parent', models.ForeignKey(blank=True, help_text='Parent folder for nested structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='references.referencefolder')),
],
options={
'verbose_name': 'Reference Folder',
'verbose_name_plural': 'Reference Folders',
'ordering': ['parent__order', 'order', 'name'],
},
),
migrations.CreateModel(
name='ReferenceDocument',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_deleted', models.BooleanField(db_index=True, default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('title', models.CharField(db_index=True, max_length=500)),
('title_ar', models.CharField(blank=True, max_length=500, verbose_name='Title (Arabic)')),
('file', models.FileField(max_length=500, upload_to=apps.references.models.document_upload_path)),
('filename', models.CharField(help_text='Original filename', max_length=500)),
('file_type', models.CharField(blank=True, help_text='File extension/type (e.g., pdf, docx, xlsx)', max_length=50)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')),
('version', models.CharField(default='1.0', help_text='Document version (e.g., 1.0, 1.1, 2.0)', max_length=20)),
('is_latest_version', models.BooleanField(db_index=True, default=True, help_text='Is this the latest version?')),
('download_count', models.IntegerField(default=0, help_text='Number of downloads')),
('last_accessed_at', models.DateTimeField(blank=True, null=True)),
('is_published', models.BooleanField(db_index=True, default=True, help_text='Is this document visible to users?')),
('tags', models.CharField(blank=True, help_text='Comma-separated tags for search (e.g., policy, procedure, handbook)', max_length=500)),
('metadata', models.JSONField(blank=True, default=dict)),
('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this document (empty = all roles)', related_name='accessible_documents', to='auth.group')),
('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')),
('parent_document', models.ForeignKey(blank=True, help_text='Previous version of this document', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='references.referencedocument')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents', to=settings.AUTH_USER_MODEL)),
('folder', models.ForeignKey(blank=True, help_text='Folder containing this document', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='references.referencefolder')),
],
options={
'verbose_name': 'Reference Document',
'verbose_name_plural': 'Reference Documents',
'ordering': ['title', '-created_at'],
},
),
migrations.CreateModel(
name='ReferenceDocumentAccess',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('action', models.CharField(choices=[('view', 'Viewed'), ('download', 'Downloaded'), ('preview', 'Previewed')], db_index=True, max_length=20)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='references.referencedocument')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='document_accesses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document Access Log',
'verbose_name_plural': 'Document Access Logs',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['document', '-created_at'], name='references__documen_396fa2_idx'), models.Index(fields=['user', '-created_at'], name='references__user_id_56bf5f_idx'), models.Index(fields=['action', '-created_at'], name='references__action_b9899d_idx')],
},
),
migrations.AddIndex(
model_name='referencefolder',
index=models.Index(fields=['hospital', 'parent', 'order'], name='references__hospita_faa66f_idx'),
),
migrations.AddIndex(
model_name='referencefolder',
index=models.Index(fields=['hospital', 'is_active'], name='references__hospita_6c43f3_idx'),
),
migrations.AddIndex(
model_name='referencedocument',
index=models.Index(fields=['hospital', 'folder', 'is_latest_version'], name='references__hospita_36d516_idx'),
),
migrations.AddIndex(
model_name='referencedocument',
index=models.Index(fields=['hospital', 'is_published'], name='references__hospita_413b58_idx'),
),
migrations.AddIndex(
model_name='referencedocument',
index=models.Index(fields=['folder', 'title'], name='references__folder__e09b4c_idx'),
),
]

View File

@ -1 +0,0 @@
# Migrations for references app

View File

@ -0,0 +1,8 @@
"""
Simulator app for testing external notification APIs.
This app provides mock endpoints that simulate external email and SMS APIs.
- Email simulator sends real emails via Django SMTP
- SMS simulator prints messages to terminal
"""
default_app_config = 'apps.simulator.apps.SimulatorConfig'

10
apps/simulator/apps.py Normal file
View File

@ -0,0 +1,10 @@
"""
Django app configuration for Simulator app.
"""
from django.apps import AppConfig
class SimulatorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.simulator'
verbose_name = 'Notification Simulator'

View File

@ -0,0 +1,352 @@
#!/usr/bin/env python
"""
HIS Simulator - Continuous patient journey event generator
This script simulates a Hospital Information System (HIS) by continuously
generating patient journey events and sending them to the PX360 API.
Usage:
python his_simulator.py [--url URL] [--delay SECONDS] [--max-patients N]
Arguments:
--url: API endpoint URL (default: http://localhost:8000/api/simulator/his-events/)
--delay: Delay between events in seconds (default: 5)
--max-patients: Maximum number of patients to simulate (default: infinite)
"""
import argparse
import json
import random
import time
import os
import sys
import django
from datetime import datetime, timedelta
from typing import List, Dict
import requests
# Add project root to Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
django.setup()
from apps.organizations.models import Hospital
# Arabic names for realistic patient data
ARABIC_FIRST_NAMES = [
"Ahmed", "Mohammed", "Abdullah", "Omar", "Ali",
"Saud", "Fahad", "Turki", "Khalid", "Youssef",
"Abdulrahman", "Abdulaziz", "Abdulwahab", "Majid", "Nasser",
"Fatima", "Aisha", "Sarah", "Nora", "Layla",
"Hessa", "Reem", "Mona", "Dalal", "Jawaher"
]
ARABIC_LAST_NAMES = [
"Al-Saud", "Al-Rashid", "Al-Qahtani", "Al-Harbi", "Al-Otaibi",
"Al-Dossary", "Al-Shammari", "Al-Mutairi", "Al-Anazi", "Al-Zahrani",
"Al-Ghamdi", "Al-Ahmari", "Al-Malki", "Al-Khaldi", "Al-Bakr"
]
# Departments and journey types
DEPARTMENTS = [
"Cardiology", "Orthopedics", "Pediatrics", "Emergency", "General",
"Internal Medicine", "Surgery", "Oncology", "Neurology", "Gynecology"
]
def get_active_hospital_codes() -> List[str]:
"""Query active hospitals from the database and return their codes"""
try:
hospital_codes = list(
Hospital.objects.filter(status='active').values_list('code', flat=True)
)
if not hospital_codes:
# Fallback to default if no active hospitals found
print("⚠️ Warning: No active hospitals found, using default ALH-main")
return ["ALH-main"]
return hospital_codes
except Exception as e:
print(f"⚠️ Error querying hospitals: {e}, using default ALH-main")
return ["ALH-main"]
JOURNEY_TYPES = {
"ems": ["EMS_STAGE_1_DISPATCHED", "EMS_STAGE_2_ON_SCENE", "EMS_STAGE_3_TRANSPORT", "EMS_STAGE_4_HANDOFF"],
"inpatient": [
"INPATIENT_STAGE_1_ADMISSION", "INPATIENT_STAGE_2_TREATMENT",
"INPATIENT_STAGE_3_NURSING", "INPATIENT_STAGE_4_LAB",
"INPATIENT_STAGE_5_RADIOLOGY", "INPATIENT_STAGE_6_DISCHARGE"
],
"opd": [
"OPD_STAGE_1_REGISTRATION", "OPD_STAGE_2_CONSULTATION",
"OPD_STAGE_3_LAB", "OPD_STAGE_4_RADIOLOGY", "OPD_STAGE_5_PHARMACY"
]
}
def generate_random_saudi_phone() -> str:
"""Generate random Saudi phone number"""
return f"+9665{random.randint(0, 9)}{random.randint(1000000, 9999999)}"
def generate_random_national_id() -> str:
"""Generate random Saudi national ID (10 digits)"""
return "".join([str(random.randint(0, 9)) for _ in range(10)])
def generate_random_mrn() -> str:
"""Generate random MRN"""
return f"MRN-{random.randint(100000, 999999)}"
def generate_random_encounter_id() -> str:
"""Generate random encounter ID"""
year = datetime.now().year
return f"ENC-{year}-{random.randint(1, 99999):05d}"
def generate_random_email(first_name: str, last_name: str) -> str:
"""Generate random email address"""
domains = ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com"]
domain = random.choice(domains)
return f"{first_name.lower()}.{last_name.lower()}@{domain}"
def generate_patient_journey() -> Dict:
"""Generate a complete or partial patient journey"""
encounter_id = generate_random_encounter_id()
mrn = generate_random_mrn()
national_id = generate_random_national_id()
first_name = random.choice(ARABIC_FIRST_NAMES)
last_name = random.choice(ARABIC_LAST_NAMES)
phone = generate_random_saudi_phone()
email = generate_random_email(first_name, last_name)
visit_type = random.choice(["ems", "inpatient", "opd"])
department = random.choice(DEPARTMENTS)
# Query active hospitals dynamically
hospital_codes = get_active_hospital_codes()
hospital_code = random.choice(hospital_codes)
# Get available event codes for this journey type
available_events = JOURNEY_TYPES[visit_type]
# Determine how many stages to complete (random: some full, some partial)
# 40% chance of full journey, 60% chance of partial
is_full_journey = random.random() < 0.4
num_stages = len(available_events) if is_full_journey else random.randint(1, len(available_events) - 1)
# Select events for this journey
journey_events = available_events[:num_stages]
# Generate events with timestamps
base_time = datetime.now()
events = []
for i, event_code in enumerate(journey_events):
# Stagger events by 1-2 hours
event_time = base_time + timedelta(hours=i*1.5, minutes=random.randint(0, 30))
event = {
"encounter_id": encounter_id,
"mrn": mrn,
"national_id": national_id,
"first_name": first_name,
"last_name": last_name,
"phone": phone,
"email": email,
"event_type": event_code,
"timestamp": event_time.isoformat() + "Z",
"visit_type": visit_type,
"department": department,
"hospital_code": hospital_code
}
events.append(event)
return {
"events": events,
"summary": {
"encounter_id": encounter_id,
"patient_name": f"{first_name} {last_name}",
"visit_type": visit_type,
"stages_completed": num_stages,
"total_stages": len(available_events),
"is_full_journey": is_full_journey,
"hospital_code": hospital_code
}
}
def send_events_to_api(api_url: str, events: List[Dict]) -> bool:
"""Send events to the PX360 API"""
try:
# API expects a dictionary with 'events' key
payload = {"events": events}
response = requests.post(
api_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=10
)
if response.status_code == 200:
return True
else:
print(f" ❌ API Error: {response.status_code} - {response.text}")
return False
except requests.exceptions.RequestException as e:
print(f" ❌ Request failed: {str(e)}")
return False
def print_journey_summary(summary: Dict, success: bool):
"""Print formatted journey summary"""
status_symbol = "" if success else ""
journey_type_symbol = {
"ems": "🚑",
"inpatient": "🏥",
"opd": "🏥"
}.get(summary["visit_type"], "📋")
status_text = "Full Journey" if summary["is_full_journey"] else "Partial Journey"
print(f"\n{status_symbol} {journey_type_symbol} Patient Journey Created")
print(f" Patient: {summary['patient_name']}")
print(f" Encounter ID: {summary['encounter_id']}")
print(f" Hospital: {summary['hospital_code']}")
print(f" Type: {summary['visit_type'].upper()} - {status_text}")
print(f" Stages: {summary['stages_completed']}/{summary['total_stages']} completed")
print(f" API Status: {'Success' if success else 'Failed'}")
def print_statistics(stats: Dict):
"""Print simulation statistics"""
print(f"\n{'='*70}")
print(f"📊 SIMULATION STATISTICS")
print(f"{'='*70}")
print(f"Total Journeys: {stats['total']}")
print(f"Successful: {stats['successful']} ({stats['success_rate']:.1f}%)")
print(f"Failed: {stats['failed']}")
print(f"Full Journeys: {stats['full_journeys']}")
print(f"Partial Journeys: {stats['partial_journeys']}")
print(f"EMS Journeys: {stats['ems_journeys']}")
print(f"Inpatient Journeys: {stats['inpatient_journeys']}")
print(f"OPD Journeys: {stats['opd_journeys']}")
print(f"Total Events Sent: {stats['total_events']}")
if stats['hospital_distribution']:
print(f"\n🏥 Hospital Distribution:")
for hospital, count in sorted(stats['hospital_distribution'].items()):
percentage = (count / stats['total']) * 100 if stats['total'] > 0 else 0
print(f" {hospital}: {count} ({percentage:.1f}%)")
print(f"{'='*70}\n")
def main():
"""Main simulator loop"""
parser = argparse.ArgumentParser(description="HIS Simulator - Continuous event generator")
parser.add_argument("--url",
default="http://localhost:8000/api/simulator/his-events/",
help="API endpoint URL")
parser.add_argument("--delay",
type=int,
default=5,
help="Delay between events in seconds")
parser.add_argument("--max-patients",
type=int,
default=0,
help="Maximum number of patients to simulate (0 = infinite)")
args = parser.parse_args()
print("="*70)
print("🏥 HIS SIMULATOR - Patient Journey Event Generator")
print("="*70)
print(f"API URL: {args.url}")
print(f"Delay: {args.delay} seconds between events")
print(f"Max Patients: {args.max_patients if args.max_patients > 0 else 'Infinite'}")
print("="*70)
print("\nStarting simulation... Press Ctrl+C to stop\n")
# Statistics
stats = {
"total": 0,
"successful": 0,
"failed": 0,
"full_journeys": 0,
"partial_journeys": 0,
"ems_journeys": 0,
"inpatient_journeys": 0,
"opd_journeys": 0,
"total_events": 0,
"hospital_distribution": {}
}
patient_count = 0
try:
while True:
# Check max patients limit
if args.max_patients > 0 and patient_count >= args.max_patients:
print(f"\n✓ Reached maximum patient limit: {args.max_patients}")
break
# Generate patient journey
journey_data = generate_patient_journey()
events = journey_data["events"]
summary = journey_data["summary"]
# Send events to API
print(f"\n📤 Sending {len(events)} events for {summary['patient_name']}...")
success = send_events_to_api(args.url, events)
# Update statistics
patient_count += 1
stats["total"] += 1
stats["total_events"] += len(events)
if success:
stats["successful"] += 1
else:
stats["failed"] += 1
if summary["is_full_journey"]:
stats["full_journeys"] += 1
else:
stats["partial_journeys"] += 1
if summary["visit_type"] == "ems":
stats["ems_journeys"] += 1
elif summary["visit_type"] == "inpatient":
stats["inpatient_journeys"] += 1
else:
stats["opd_journeys"] += 1
# Track hospital distribution
hospital = summary["hospital_code"]
stats["hospital_distribution"][hospital] = stats["hospital_distribution"].get(hospital, 0) + 1
# Calculate success rate
stats["success_rate"] = (stats["successful"] / stats["total"]) * 100 if stats["total"] > 0 else 0
# Print journey summary
print_journey_summary(summary, success)
# Print statistics every 10 patients
if patient_count % 10 == 0:
print_statistics(stats)
# Wait before next patient
time.sleep(args.delay)
except KeyboardInterrupt:
print("\n\n⏹️ Simulation stopped by user")
print_statistics(stats)
print("Goodbye! 👋\n")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,290 @@
"""
Management command to seed journey templates and surveys for HIS simulator.
This command creates:
1. Journey templates (EMS, Inpatient, OPD) with random stages
2. Survey templates with questions for each journey type
3. Associates surveys with journey stages
"""
import random
from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.journeys.models import (
JourneyType,
PatientJourneyTemplate,
PatientJourneyStageTemplate
)
from apps.surveys.models import (
SurveyTemplate,
SurveyQuestion,
QuestionType,
)
from apps.organizations.models import Hospital, Department
class Command(BaseCommand):
help = 'Seed journey templates and surveys for HIS simulator'
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Starting to seed journey templates and surveys...'))
# Get or create a default hospital
from apps.core.models import StatusChoices
hospital, _ = Hospital.objects.get_or_create(
code='ALH-main',
defaults={
'name': 'Al Hammadi Hospital',
'name_ar': 'مستشفى الحمادي',
'city': 'Riyadh',
'status': StatusChoices.ACTIVE
}
)
# Get or create some departments
departments = self.get_or_create_departments(hospital)
# Create journey templates and surveys
self.create_ems_journey(hospital, departments)
self.create_inpatient_journey(hospital, departments)
self.create_opd_journey(hospital, departments)
self.stdout.write(self.style.SUCCESS('✓ Successfully seeded journey templates and surveys!'))
def get_or_create_departments(self, hospital):
"""Get or create departments for the hospital"""
departments = {}
dept_data = [
('EMERGENCY', 'Emergency Department', 'قسم الطوارئ'),
('CARDIOLOGY', 'Cardiology', 'أمراض القلب'),
('ORTHO', 'Orthopedics', 'جراحة العظام'),
('PEDS', 'Pediatrics', 'طب الأطفال'),
('LAB', 'Laboratory', 'المختبر'),
('RADIO', 'Radiology', 'الأشعة'),
('PHARMACY', 'Pharmacy', 'الصيدلية'),
('NURSING', 'Nursing', 'التمريض'),
]
for code, name_en, name_ar in dept_data:
from apps.core.models import StatusChoices
dept, _ = Department.objects.get_or_create(
hospital=hospital,
code=code,
defaults={
'name': name_en,
'name_ar': name_ar,
'status': StatusChoices.ACTIVE
}
)
departments[code] = dept
return departments
def create_ems_journey(self, hospital, departments):
"""Create EMS journey template with random stages (2-4 stages)"""
self.stdout.write('\nCreating EMS journey...')
# Create survey template
survey_template = SurveyTemplate.objects.create(
name='EMS Experience Survey',
name_ar='استبيان تجربة الطوارئ',
hospital=hospital,
is_active=True
)
# Add survey questions
questions = [
('How satisfied were you with the ambulance arrival time?', 'كم كنت راضيًا عن وقت وصول الإسعاف?'),
('How would you rate the ambulance staff professionalism?', 'كيف تقيم احترافية طاقم الإسعاف?'),
('Did the ambulance staff explain what they were doing?', 'هل شرح طاقم الإسعاف ما كانوا يفعلونه?'),
('How was the overall ambulance experience?', 'كيف كانت تجربة الإسعاف بشكل عام?'),
]
for i, (q_en, q_ar) in enumerate(questions, 1):
SurveyQuestion.objects.create(
survey_template=survey_template,
text=q_en,
text_ar=q_ar,
question_type=QuestionType.RATING,
order=i,
is_required=True
)
# Create journey template
journey_template = PatientJourneyTemplate.objects.create(
name='EMS Patient Journey',
name_ar='رحلة المريض للطوارئ',
journey_type=JourneyType.EMS,
description='Emergency medical services patient journey',
hospital=hospital,
is_active=True,
is_default=True,
send_post_discharge_survey=True,
post_discharge_survey_delay_hours=1
)
# Create random stages (2-4 stages)
num_stages = random.randint(2, 4)
stage_templates = [
('Ambulance Dispatch', 'إرسال الإسعاف', 'EMS_STAGE_1_DISPATCHED'),
('On Scene Care', 'الرعاية في الموقع', 'EMS_STAGE_2_ON_SCENE'),
('Patient Transport', 'نقل المريض', 'EMS_STAGE_3_TRANSPORT'),
('Hospital Handoff', 'تسليم المستشفى', 'EMS_STAGE_4_HANDOFF'),
]
for i in range(num_stages):
stage_template = PatientJourneyStageTemplate.objects.create(
journey_template=journey_template,
name=stage_templates[i][0],
name_ar=stage_templates[i][1],
code=stage_templates[i][2],
order=i + 1,
trigger_event_code=stage_templates[i][2],
survey_template=survey_template,
is_optional=False,
is_active=True
)
self.stdout.write(self.style.SUCCESS(f' ✓ EMS journey created with {num_stages} stages'))
def create_inpatient_journey(self, hospital, departments):
"""Create Inpatient journey template with random stages (3-6 stages)"""
self.stdout.write('\nCreating Inpatient journey...')
# Create survey template
survey_template = SurveyTemplate.objects.create(
name='Inpatient Experience Survey',
name_ar='استبيان تجربة المرضى الداخليين',
hospital=hospital,
is_active=True
)
# Add survey questions
questions = [
('How satisfied were you with the admission process?', 'كم كنت راضيًا عن عملية القبول?'),
('How would you rate the nursing care you received?', 'كيف تقيم الرعاية التمريضية التي تلقيتها?'),
('Did the doctors explain your treatment clearly?', 'هل أوضح الأطباء علاجك بوضوح?'),
('How clean and comfortable was your room?', 'كم كانت نظافة وراحة غرفتك?'),
('How satisfied were you with the food service?', 'كم كنت راضيًا عن خدمة الطعام?'),
('How would you rate the discharge process?', 'كيف تقيم عملية الخروج?'),
('Would you recommend this hospital to others?', 'هل ستنصح هذا المستشفى للآخرين?'),
]
for i, (q_en, q_ar) in enumerate(questions, 1):
SurveyQuestion.objects.create(
survey_template=survey_template,
text=q_en,
text_ar=q_ar,
question_type=QuestionType.RATING,
order=i,
is_required=True
)
# Create journey template
journey_template = PatientJourneyTemplate.objects.create(
name='Inpatient Patient Journey',
name_ar='رحلة المريض الداخلي',
journey_type=JourneyType.INPATIENT,
description='Inpatient journey from admission to discharge',
hospital=hospital,
is_active=True,
is_default=True,
send_post_discharge_survey=True,
post_discharge_survey_delay_hours=24
)
# Create random stages (3-6 stages)
num_stages = random.randint(3, 6)
stage_templates = [
('Admission', 'القبول', 'INPATIENT_STAGE_1_ADMISSION'),
('Treatment', 'العلاج', 'INPATIENT_STAGE_2_TREATMENT'),
('Nursing Care', 'الرعاية التمريضية', 'INPATIENT_STAGE_3_NURSING'),
('Lab Tests', 'الفحوصات المخبرية', 'INPATIENT_STAGE_4_LAB'),
('Radiology', 'الأشعة', 'INPATIENT_STAGE_5_RADIOLOGY'),
('Discharge', 'الخروج', 'INPATIENT_STAGE_6_DISCHARGE'),
]
for i in range(num_stages):
stage_template = PatientJourneyStageTemplate.objects.create(
journey_template=journey_template,
name=stage_templates[i][0],
name_ar=stage_templates[i][1],
code=stage_templates[i][2],
order=i + 1,
trigger_event_code=stage_templates[i][2],
survey_template=survey_template,
is_optional=False,
is_active=True
)
self.stdout.write(self.style.SUCCESS(f' ✓ Inpatient journey created with {num_stages} stages'))
def create_opd_journey(self, hospital, departments):
"""Create OPD journey template with random stages (3-5 stages)"""
self.stdout.write('\nCreating OPD journey...')
# Create survey template
survey_template = SurveyTemplate.objects.create(
name='OPD Experience Survey',
name_ar='استبيان تجربة العيادات الخارجية',
hospital=hospital,
is_active=True
)
# Add survey questions
questions = [
('How satisfied were you with the registration process?', 'كم كنت راضيًا عن عملية التسجيل?'),
('How long did you wait to see the doctor?', 'كم مدة انتظارك لرؤية الطبيب?'),
('Did the doctor listen to your concerns?', 'هل استمع الطبيب لمخاوفك?'),
('Did the doctor explain your diagnosis and treatment?', 'هل أوضح الطبيب تشخيصك وعلاجك?'),
('How satisfied were you with the lab services?', 'كم كنت راضيًا عن خدمات المختبر?'),
('How satisfied were you with the pharmacy services?', 'كم كنت راضيًا عن خدمات الصيدلية?'),
('How would you rate your overall visit experience?', 'كيف تقيم تجربة زيارتك بشكل عام?'),
]
for i, (q_en, q_ar) in enumerate(questions, 1):
SurveyQuestion.objects.create(
survey_template=survey_template,
text=q_en,
text_ar=q_ar,
question_type=QuestionType.RATING,
order=i,
is_required=True
)
# Create journey template
journey_template = PatientJourneyTemplate.objects.create(
name='OPD Patient Journey',
name_ar='رحلة المريض للعيادات الخارجية',
journey_type=JourneyType.OPD,
description='Outpatient department patient journey',
hospital=hospital,
is_active=True,
is_default=True,
send_post_discharge_survey=True,
post_discharge_survey_delay_hours=2
)
# Create random stages (3-5 stages)
num_stages = random.randint(3, 5)
stage_templates = [
('Registration', 'التسجيل', 'OPD_STAGE_1_REGISTRATION'),
('Consultation', 'الاستشارة', 'OPD_STAGE_2_CONSULTATION'),
('Lab Tests', 'الفحوصات المخبرية', 'OPD_STAGE_3_LAB'),
('Radiology', 'الأشعة', 'OPD_STAGE_4_RADIOLOGY'),
('Pharmacy', 'الصيدلية', 'OPD_STAGE_5_PHARMACY'),
]
for i in range(num_stages):
stage_template = PatientJourneyStageTemplate.objects.create(
journey_template=journey_template,
name=stage_templates[i][0],
name_ar=stage_templates[i][1],
code=stage_templates[i][2],
order=i + 1,
trigger_event_code=stage_templates[i][2],
survey_template=survey_template,
is_optional=False,
is_active=True
)
self.stdout.write(self.style.SUCCESS(f' ✓ OPD journey created with {num_stages} stages'))

View File

@ -0,0 +1,25 @@
"""
Serializers for HIS simulator API endpoints
"""
from rest_framework import serializers
class HISJourneyEventSerializer(serializers.Serializer):
"""Serializer for individual HIS journey event"""
encounter_id = serializers.CharField(max_length=100)
mrn = serializers.CharField(max_length=50)
national_id = serializers.CharField(max_length=20)
first_name = serializers.CharField(max_length=200)
last_name = serializers.CharField(max_length=200)
phone = serializers.CharField(max_length=20)
email = serializers.EmailField()
event_type = serializers.CharField(max_length=100)
timestamp = serializers.DateTimeField()
visit_type = serializers.ChoiceField(choices=['ems', 'inpatient', 'opd'])
department = serializers.CharField(max_length=200)
hospital_code = serializers.CharField(max_length=50)
class HISJourneyEventListSerializer(serializers.Serializer):
"""Serializer for list of HIS journey events"""
events = HISJourneyEventSerializer(many=True)

31
apps/simulator/urls.py Normal file
View File

@ -0,0 +1,31 @@
"""
URL configuration for Simulator app.
This module defines the URL patterns for simulator endpoints:
- /api/simulator/send-email - POST - Email simulator
- /api/simulator/send-sms - POST - SMS simulator
- /api/simulator/his-events/ - POST - HIS journey events handler
- /api/simulator/health/ - GET - Health check
- /api/simulator/reset/ - GET - Reset simulator
"""
from django.urls import path
from . import views
app_name = 'simulator'
urlpatterns = [
# Email simulator endpoint (no trailing slash for POST requests)
path('send-email', views.email_simulator, name='email_simulator'),
# SMS simulator endpoint (no trailing slash for POST requests)
path('send-sms', views.sms_simulator, name='sms_simulator'),
# HIS journey events endpoint
path('his-events/', views.his_events_handler, name='his_events'),
# Health check endpoint
path('health/', views.health_check, name='health_check'),
# Reset simulator endpoint
path('reset/', views.reset_simulator, name='reset_simulator'),
]

671
apps/simulator/views.py Normal file
View File

@ -0,0 +1,671 @@
"""
Simulator views for testing external notification APIs.
This module provides API endpoints that:
- Simulate external email and SMS services
- Receive and process HIS journey events
- Create journeys, send surveys, and trigger notifications
"""
import logging
from datetime import datetime, timedelta
from django.conf import settings
from django.core.mail import send_mail
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
import json
from .serializers import HISJourneyEventSerializer, HISJourneyEventListSerializer
from apps.journeys.models import (
JourneyType,
PatientJourneyTemplate,
PatientJourneyInstance,
PatientJourneyStageInstance,
StageStatus
)
from apps.organizations.models import Hospital, Department, Patient
from apps.surveys.models import SurveyTemplate, SurveyInstance
from apps.notifications.services import NotificationService
logger = logging.getLogger(__name__)
# Request counter for tracking
request_counter = {'email': 0, 'sms': 0}
# Request history (last 10 requests)
request_history = []
def log_simulator_request(channel, payload, status):
"""Log simulator request to history and file."""
request_id = len(request_history) + 1
entry = {
'id': request_id,
'channel': channel,
'timestamp': datetime.now().isoformat(),
'status': status,
'payload': payload
}
request_history.append(entry)
# Keep only last 10 requests
if len(request_history) > 10:
request_history.pop(0)
# Log to file
logger.info(f"[Simulator] {channel.upper()} Request #{request_id}: {status}")
@csrf_exempt
@require_http_methods(["POST"])
def email_simulator(request):
"""
Simulate external email API endpoint.
Accepts POST request with JSON payload:
{
"to": "recipient@example.com",
"subject": "Email subject",
"message": "Plain text message",
"html_message": "Optional HTML content"
}
Sends real email via Django SMTP and returns 200 OK.
"""
request_counter['email'] += 1
try:
# Parse request body
data = json.loads(request.body)
# Validate required fields
required_fields = ['to', 'subject', 'message']
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
response = {
'success': False,
'error': f'Missing required fields: {", ".join(missing_fields)}'
}
log_simulator_request('email', data, 'failed')
return JsonResponse(response, status=400)
# Extract fields
to_email = data['to']
subject = data['subject']
message = data['message']
html_message = data.get('html_message', None)
# Log the request
logger.info(f"[Email Simulator] Sending email to {to_email}: {subject}")
# Print formatted email to terminal
print(f"\n{'' + ''*68 + ''}")
print(f"{' ' * 15}📧 EMAIL SIMULATOR{' ' * 34}")
print(f"{''*68}")
print(f"║ Request #: {request_counter['email']:<52}")
print(f"{''*68}")
print(f"║ To: {to_email:<52}")
print(f"║ Subject: {subject[:52]:<52}")
print(f"{''*68}")
print(f"║ Message:{' '*55}")
# Word wrap message
words = message.split()
line = ""
for word in words:
if len(line) + len(word) + 1 > 68:
print(f"{line:<68}")
line = "" + word
else:
if line == "":
line += word
else:
line += " " + word
# Print last line if not empty
if line != "":
print(f"{line:<68}")
# Print HTML section if present
if html_message:
print(f"{''*68}")
print(f"║ HTML Message:{' '*48}")
html_preview = html_message[:200].replace('\n', ' ')
print(f"{html_preview:<66}")
if len(html_message) > 200:
print(f"║ ... ({len(html_message)} total characters){' '*30}")
print(f"{''*68}\n")
# Send real email via Django SMTP
try:
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[to_email],
html_message=html_message,
fail_silently=False
)
logger.info(f"[Email Simulator] Email sent via SMTP to {to_email}")
email_status = 'sent'
except Exception as email_error:
logger.error(f"[Email Simulator] SMTP Error: {str(email_error)}")
# Log as 'partial' since we displayed it but failed to send
email_status = 'partial'
# Return error response
response = {
'success': False,
'error': f'Email displayed but failed to send via SMTP: {str(email_error)}',
'data': {
'to': to_email,
'subject': subject,
'message_length': len(message),
'has_html': html_message is not None,
'displayed': True,
'sent': False
}
}
log_simulator_request('email', data, email_status)
return JsonResponse(response, status=500)
# Log success
logger.info(f"[Email Simulator] Email sent successfully to {to_email}")
log_simulator_request('email', data, email_status)
response = {
'success': True,
'message': 'Email sent successfully',
'data': {
'to': to_email,
'subject': subject,
'message_length': len(message),
'has_html': html_message is not None
}
}
return JsonResponse(response, status=200)
except json.JSONDecodeError:
response = {
'success': False,
'error': 'Invalid JSON format'
}
log_simulator_request('email', {}, 'failed')
return JsonResponse(response, status=400)
except Exception as e:
logger.error(f"[Email Simulator] Error: {str(e)}")
response = {
'success': False,
'error': str(e)
}
log_simulator_request('email', data if 'data' in locals() else {}, 'failed')
return JsonResponse(response, status=500)
@csrf_exempt
@require_http_methods(["POST"])
def sms_simulator(request):
"""
Simulate external SMS API endpoint.
Accepts POST request with JSON payload:
{
"to": "+966501234567",
"message": "SMS message text"
}
Prints SMS to terminal with formatted output and returns 200 OK.
"""
request_counter['sms'] += 1
try:
# Parse request body
data = json.loads(request.body)
# Validate required fields
required_fields = ['to', 'message']
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
response = {
'success': False,
'error': f'Missing required fields: {", ".join(missing_fields)}'
}
log_simulator_request('sms', data, 'failed')
return JsonResponse(response, status=400)
# Extract fields
to_phone = data['to']
message = data['message']
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# Log the request
logger.info(f"[SMS Simulator] Sending SMS to {to_phone}")
# Print formatted SMS to terminal
print(f"\n{'' + ''*68 + ''}")
print(f"{' ' * 15}📱 SMS SIMULATOR{' ' * 35}")
print(f"{''*68}")
print(f"║ Request #: {request_counter['sms']:<52}")
print(f"{''*68}")
print(f"║ To: {to_phone:<52}")
print(f"║ Time: {timestamp:<52}")
print(f"{''*68}")
print(f"║ Message:{' '*55}")
# Word wrap message
words = message.split()
line = ""
for word in words:
if len(line) + len(word) + 1 > 68:
print(f"{line:<68}")
line = "" + word
else:
if line == "":
line += word
else:
line += " " + word
# Print last line if not empty
if line != "":
print(f"{line:<68}")
print(f"{''*68}\n")
# Log success
logger.info(f"[SMS Simulator] SMS sent to {to_phone}: {message[:50]}...")
log_simulator_request('sms', data, 'sent')
response = {
'success': True,
'message': 'SMS sent successfully',
'data': {
'to': to_phone,
'message_length': len(message)
}
}
return JsonResponse(response, status=200)
except json.JSONDecodeError:
response = {
'success': False,
'error': 'Invalid JSON format'
}
log_simulator_request('sms', {}, 'failed')
return JsonResponse(response, status=400)
except Exception as e:
logger.error(f"[SMS Simulator] Error: {str(e)}")
response = {
'success': False,
'error': str(e)
}
log_simulator_request('sms', data if 'data' in locals() else {}, 'failed')
return JsonResponse(response, status=500)
@csrf_exempt
@require_http_methods(["GET"])
def health_check(request):
"""
Health check endpoint for simulator.
Returns simulator status and statistics.
"""
return JsonResponse({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'statistics': {
'total_requests': request_counter['email'] + request_counter['sms'],
'email_requests': request_counter['email'],
'sms_requests': request_counter['sms']
},
'recent_requests': request_history[-5:] # Last 5 requests
}, status=200)
@csrf_exempt
@require_http_methods(["GET"])
def reset_simulator(request):
"""
Reset simulator statistics and history.
Clears request counter and history.
"""
global request_counter, request_history
request_counter = {'email': 0, 'sms': 0}
request_history = []
logger.info("[Simulator] Reset statistics and history")
return JsonResponse({
'success': True,
'message': 'Simulator reset successfully'
}, status=200)
from rest_framework.permissions import AllowAny
from rest_framework.decorators import permission_classes
@api_view(['POST'])
@permission_classes([AllowAny])
def his_events_handler(request):
"""
HIS Events API Endpoint
Receives patient journey events from HIS simulator:
- Creates or updates patient records
- Creates journey instances
- Processes stage completions
- Sends post-discharge surveys when journey is complete
Expected payload:
{
"events": [
{
"encounter_id": "ENC-2024-001",
"mrn": "MRN-12345",
"national_id": "1234567890",
"first_name": "Ahmed",
"last_name": "Mohammed",
"phone": "+966501234567",
"email": "patient@example.com",
"event_type": "OPD_STAGE_1_REGISTRATION",
"timestamp": "2024-01-20T10:30:00Z",
"visit_type": "opd",
"department": "Cardiology",
"hospital_code": "ALH-main"
}
]
}
"""
try:
# Validate request data
serializer = HISJourneyEventListSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{'error': 'Invalid data', 'details': serializer.errors},
status=status.HTTP_400_BAD_REQUEST
)
events_data = serializer.validated_data['events']
# Process each event
results = []
survey_invitations_sent = []
for event_data in events_data:
result = process_his_event(event_data)
results.append(result)
# Track if survey was sent
if result.get('survey_sent'):
survey_invitations_sent.append({
'encounter_id': event_data['encounter_id'],
'survey_id': result['survey_id'],
'survey_url': result['survey_url']
})
return Response({
'success': True,
'message': f'Processed {len(events_data)} events successfully',
'results': results,
'surveys_sent': len(survey_invitations_sent),
'survey_details': survey_invitations_sent
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"[HIS Events Handler] Error: {str(e)}", exc_info=True)
return Response(
{'error': 'Internal server error', 'details': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def process_his_event(event_data):
"""
Process a single HIS journey event
Steps:
1. Get or create patient
2. Get or create journey instance
3. Find and update stage instance
4. Check if journey is complete and send survey if needed
"""
try:
# 1. Get or create patient
patient = get_or_create_patient(event_data)
# 2. Get or create journey instance
journey_instance = get_or_create_journey_instance(event_data, patient)
# 3. Find and update stage instance
stage_instance = update_stage_instance(journey_instance, event_data)
result = {
'encounter_id': event_data['encounter_id'],
'patient_id': str(patient.id) if patient else None,
'journey_id': str(journey_instance.id),
'stage_id': str(stage_instance.id) if stage_instance else None,
'stage_status': stage_instance.status if stage_instance else 'not_found',
'survey_sent': False
}
# 4. Check if journey is complete and send survey
if journey_instance.is_complete():
survey_result = send_post_discharge_survey(journey_instance, patient)
if survey_result:
result.update(survey_result)
return result
except Exception as e:
logger.error(f"[Process HIS Event] Error for encounter {event_data.get('encounter_id')}: {str(e)}", exc_info=True)
return {
'encounter_id': event_data.get('encounter_id'),
'error': str(e),
'success': False
}
def get_or_create_patient(event_data):
"""Get or create patient from event data"""
try:
patient, created = Patient.objects.get_or_create(
mrn=event_data['mrn'],
defaults={
'first_name': event_data['first_name'],
'last_name': event_data['last_name'],
'phone': event_data['phone'],
'email': event_data['email'],
'national_id': event_data.get('national_id', ''),
}
)
logger.info(f"{'Created' if created else 'Found'} patient {patient.mrn}: {patient.get_full_name()}")
return patient
except Exception as e:
logger.error(f"Error creating patient: {str(e)}")
raise
def get_or_create_journey_instance(event_data, patient):
"""Get or create journey instance for this encounter"""
try:
# Get hospital from event data or default to ALH-main
hospital_code = event_data.get('hospital_code', 'ALH-main')
hospital = Hospital.objects.filter(code=hospital_code).first()
if not hospital:
raise ValueError(f"Hospital with code '{hospital_code}' not found. Please run seed_journey_surveys command first.")
# Map visit_type to JourneyType
journey_type_map = {
'ems': JourneyType.EMS,
'inpatient': JourneyType.INPATIENT,
'opd': JourneyType.OPD
}
journey_type = journey_type_map.get(event_data['visit_type'])
if not journey_type:
raise ValueError(f"Invalid visit_type: {event_data['visit_type']}")
# Get journey template
journey_template = PatientJourneyTemplate.objects.filter(
hospital=hospital,
journey_type=journey_type,
is_active=True
).first()
if not journey_template:
raise ValueError(f"No active journey template found for {journey_type}")
# Get or create journey instance
journey_instance, created = PatientJourneyInstance.objects.get_or_create(
encounter_id=event_data['encounter_id'],
defaults={
'journey_template': journey_template,
'patient': patient,
'hospital': hospital,
'status': 'active'
}
)
# Create stage instances if this is a new journey
if created:
for stage_template in journey_template.stages.filter(is_active=True):
PatientJourneyStageInstance.objects.create(
journey_instance=journey_instance,
stage_template=stage_template,
status=StageStatus.PENDING
)
logger.info(f"Created new journey instance {journey_instance.id} with {journey_template.stages.count()} stages")
return journey_instance
except Exception as e:
logger.error(f"Error creating journey instance: {str(e)}")
raise
def update_stage_instance(journey_instance, event_data):
"""Find and update stage instance based on event_type"""
try:
# Find stage template by trigger_event_code
stage_template = journey_instance.journey_template.stages.filter(
trigger_event_code=event_data['event_type'],
is_active=True
).first()
if not stage_template:
logger.warning(f"No stage template found for event_type: {event_data['event_type']}")
return None
# Get or create stage instance
stage_instance, created = PatientJourneyStageInstance.objects.get_or_create(
journey_instance=journey_instance,
stage_template=stage_template,
defaults={
'status': StageStatus.PENDING
}
)
# Complete the stage
if stage_instance.status != StageStatus.COMPLETED:
from django.utils import timezone
stage_instance.status = StageStatus.COMPLETED
stage_instance.completed_at = timezone.now()
stage_instance.save()
logger.info(f"Completed stage {stage_template.name} for journey {journey_instance.encounter_id}")
else:
logger.info(f"Stage {stage_template.name} already completed for journey {journey_instance.encounter_id}")
return stage_instance
except Exception as e:
logger.error(f"Error updating stage instance: {str(e)}")
raise
def send_post_discharge_survey(journey_instance, patient):
"""
Send post-discharge survey to patient
Creates a survey instance and sends invitation via email and SMS
"""
try:
# Check if journey template has post-discharge survey enabled
journey_template = journey_instance.journey_template
if not journey_template.send_post_discharge_survey:
return None
# Check if survey already sent for this journey
existing_survey = SurveyInstance.objects.filter(
journey_instance=journey_instance
).first()
if existing_survey:
logger.info(f"Survey already sent for journey {journey_instance.encounter_id}")
return None
# Get survey template from journey template
# Use first stage's survey template as the comprehensive survey
first_stage = journey_template.stages.filter(is_active=True).order_by('order').first()
if not first_stage or not first_stage.survey_template:
logger.warning(f"No survey template found for journey {journey_instance.encounter_id}")
return None
survey_template = first_stage.survey_template
# Create survey instance
survey_instance = SurveyInstance.objects.create(
survey_template=survey_template,
patient=patient,
journey_instance=journey_instance,
hospital=journey_instance.hospital,
delivery_channel='email', # Primary channel is email
status='pending',
recipient_email=patient.email,
recipient_phone=patient.phone
)
logger.info(f"Created survey instance {survey_instance.id} for journey {journey_instance.encounter_id}")
# Send survey invitation via email
try:
email_log = NotificationService.send_survey_invitation(survey_instance, language='en')
logger.info(f"Survey invitation sent via email to {patient.email}")
except Exception as e:
logger.error(f"Error sending survey email: {str(e)}")
# Also send via SMS (as backup)
try:
sms_log = NotificationService.send_sms(
phone=patient.phone,
message=f"Your experience survey is ready: {survey_instance.get_survey_url()}",
related_object=survey_instance,
metadata={'survey_id': str(survey_instance.id)}
)
logger.info(f"Survey invitation sent via SMS to {patient.phone}")
except Exception as e:
logger.error(f"Error sending survey SMS: {str(e)}")
# Return survey details
return {
'survey_sent': True,
'survey_id': str(survey_instance.id),
'survey_url': survey_instance.get_survey_url(),
'delivery_channel': 'email_and_sms'
}
except Exception as e:
logger.error(f"Error sending post-discharge survey: {str(e)}", exc_info=True)
return {
'survey_sent': False,
'error': str(e)
}

View File

@ -1,38 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='SocialMediaComment',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('youtube', 'YouTube'), ('twitter', 'Twitter/X'), ('linkedin', 'LinkedIn'), ('tiktok', 'TikTok'), ('google', 'Google Reviews')], db_index=True, help_text='Social media platform', max_length=50)),
('comment_id', models.CharField(db_index=True, help_text='Unique comment ID from the platform', max_length=255)),
('comments', models.TextField(help_text='Comment text content')),
('author', models.CharField(blank=True, help_text='Comment author', max_length=255, null=True)),
('raw_data', models.JSONField(default=dict, help_text='Complete raw data from platform API')),
('post_id', models.CharField(blank=True, help_text='ID of the post/media', max_length=255, null=True)),
('media_url', models.URLField(blank=True, help_text='URL to associated media', max_length=500, null=True)),
('like_count', models.IntegerField(default=0, help_text='Number of likes')),
('reply_count', models.IntegerField(default=0, help_text='Number of replies')),
('rating', models.IntegerField(blank=True, db_index=True, help_text='Star rating (1-5) for review platforms like Google Reviews', null=True)),
('published_at', models.DateTimeField(blank=True, db_index=True, help_text='When the comment was published', null=True)),
('scraped_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='When the comment was scraped')),
('ai_analysis', models.JSONField(blank=True, db_index=True, default=dict, help_text='Complete AI analysis in bilingual format (en/ar) with sentiment, summaries, keywords, topics, entities, and emotions')),
],
options={
'ordering': ['-published_at'],
'indexes': [models.Index(fields=['platform'], name='social_soci_platfor_307afd_idx'), models.Index(fields=['published_at'], name='social_soci_publish_5f2b85_idx'), models.Index(fields=['platform', '-published_at'], name='social_soci_platfor_4f0230_idx'), models.Index(fields=['ai_analysis'], name='idx_ai_analysis')],
'unique_together': {('platform', 'comment_id')},
},
),
]

View File

@ -1,119 +0,0 @@
# Generated by Django 6.0 on 2026-01-12 09:50
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StandardCategory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')),
('description', models.TextField(blank=True)),
('order', models.PositiveIntegerField(default=0, help_text='Display order')),
('is_active', models.BooleanField(db_index=True, default=True)),
],
options={
'verbose_name': 'Standard Category',
'verbose_name_plural': 'Standard Categories',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='StandardSource',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')),
('code', models.CharField(db_index=True, max_length=50, unique=True)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
],
options={
'verbose_name': 'Standard Source',
'verbose_name_plural': 'Standard Sources',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Standard',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(db_index=True, help_text='e.g., CBAHI-PS-01', max_length=50)),
('title', models.CharField(max_length=300)),
('title_ar', models.CharField(blank=True, max_length=300, verbose_name='Title (Arabic)')),
('description', models.TextField(help_text='Full description of the standard')),
('effective_date', models.DateField(blank=True, help_text='When standard becomes effective', null=True)),
('review_date', models.DateField(blank=True, help_text='Next review date', null=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('department', models.ForeignKey(blank=True, help_text='Department-specific standard (null if applicable to all)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='standards', to='organizations.department')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardcategory')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardsource')),
],
options={
'verbose_name': 'Standard',
'verbose_name_plural': 'Standards',
'ordering': ['source', 'category', 'code'],
},
),
migrations.CreateModel(
name='StandardCompliance',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('not_assessed', 'Not Assessed'), ('met', 'Met'), ('partially_met', 'Partially Met'), ('not_met', 'Not Met')], db_index=True, default='not_assessed', max_length=20)),
('last_assessed_date', models.DateField(blank=True, help_text='Date of last assessment', null=True)),
('notes', models.TextField(blank=True, help_text='Assessment notes')),
('evidence_summary', models.TextField(blank=True, help_text='Summary of evidence')),
('assessor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assessments', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='organizations.department')),
('standard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='standards.standard')),
],
options={
'verbose_name': 'Standard Compliance',
'verbose_name_plural': 'Standard Compliance',
'ordering': ['-created_at'],
'unique_together': {('department', 'standard')},
},
),
migrations.CreateModel(
name='StandardAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='standards/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'zip'])])),
('filename', models.CharField(help_text='Original filename', max_length=255)),
('description', models.TextField(blank=True, help_text='Attachment description')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_standards_attachments', to=settings.AUTH_USER_MODEL)),
('compliance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='standards.standardcompliance')),
],
options={
'verbose_name': 'Standard Attachment',
'verbose_name_plural': 'Standard Attachments',
'ordering': ['-created_at'],
},
),
]

View File

@ -3,15 +3,16 @@ 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
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', 'weight']
fields = ['order', 'text', 'question_type', 'is_required']
ordering = ['order']
@ -20,19 +21,19 @@ class SurveyTemplateAdmin(admin.ModelAdmin):
"""Survey template admin"""
list_display = [
'name', 'survey_type', 'hospital', 'scoring_method',
'negative_threshold', 'get_question_count', 'is_active', 'version'
'negative_threshold', 'get_question_count', 'is_active'
]
list_filter = ['survey_type', 'scoring_method', 'is_active', 'hospital']
search_fields = ['name', 'name_ar', 'description']
search_fields = ['name', 'name_ar']
ordering = ['hospital', 'name']
inlines = [SurveyQuestionInline]
fieldsets = (
(None, {
'fields': ('name', 'name_ar', 'description', 'description_ar')
'fields': ('name', 'name_ar')
}),
('Configuration', {
'fields': ('hospital', 'survey_type', 'version')
'fields': ('hospital', 'survey_type')
}),
('Scoring', {
'fields': ('scoring_method', 'negative_threshold')
@ -57,7 +58,7 @@ class SurveyQuestionAdmin(admin.ModelAdmin):
"""Survey question admin"""
list_display = [
'survey_template', 'order', 'text_preview',
'question_type', 'is_required', 'weight'
'question_type', 'is_required'
]
list_filter = ['survey_template', 'question_type', 'is_required']
search_fields = ['text', 'text_ar']
@ -71,20 +72,12 @@ class SurveyQuestionAdmin(admin.ModelAdmin):
'fields': ('text', 'text_ar')
}),
('Configuration', {
'fields': ('question_type', 'is_required', 'weight')
'fields': ('question_type', 'is_required')
}),
('Choices (for multiple choice)', {
'fields': ('choices_json',),
'classes': ('collapse',)
}),
('Branch Logic', {
'fields': ('branch_logic',),
'classes': ('collapse',)
}),
('Help Text', {
'fields': ('help_text', 'help_text_ar'),
'classes': ('collapse',)
}),
('Metadata', {
'fields': ('created_at', 'updated_at')
}),
@ -114,12 +107,26 @@ class SurveyResponseInline(admin.TabularInline):
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']
can_delete = False
def has_add_permission(self, request, obj=None):
return False
@admin.register(SurveyInstance)
class SurveyInstanceAdmin(admin.ModelAdmin):
"""Survey instance admin"""
list_display = [
'survey_template', 'patient', 'encounter_id',
'status_badge', 'delivery_channel', 'total_score',
'status_badge', 'delivery_channel', 'open_count',
'time_spent_display', 'total_score',
'is_negative', 'sent_at', 'completed_at'
]
list_filter = [
@ -131,14 +138,14 @@ class SurveyInstanceAdmin(admin.ModelAdmin):
'encounter_id', 'access_token'
]
ordering = ['-created_at']
inlines = [SurveyResponseInline]
inlines = [SurveyResponseInline, SurveyTrackingInline]
fieldsets = (
(None, {
'fields': ('survey_template', 'patient', 'encounter_id')
}),
('Journey Linkage', {
'fields': ('journey_instance', 'journey_stage_instance'),
'fields': ('journey_instance',),
'classes': ('collapse',)
}),
('Delivery', {
@ -150,6 +157,9 @@ class SurveyInstanceAdmin(admin.ModelAdmin):
('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')
}),
@ -161,23 +171,27 @@ class SurveyInstanceAdmin(admin.ModelAdmin):
readonly_fields = [
'access_token', 'token_expires_at', 'sent_at', 'opened_at',
'completed_at', 'total_score', 'is_negative',
'completed_at', 'open_count', 'last_opened_at', 'time_spent_seconds',
'total_score', 'is_negative',
'created_at', 'updated_at'
]
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related(
'survey_template', 'patient', 'journey_instance', 'journey_stage_instance'
).prefetch_related('responses')
'survey_template', 'patient', 'journey_instance'
).prefetch_related('responses', 'tracking_events')
def status_badge(self, obj):
"""Display status with color badge"""
colors = {
'pending': 'warning',
'active': 'info',
'sent': 'secondary',
'viewed': 'info',
'in_progress': 'warning',
'completed': 'success',
'cancelled': 'secondary',
'abandoned': 'danger',
'expired': 'secondary',
'cancelled': 'dark',
}
color = colors.get(obj.status, 'secondary')
return format_html(
@ -186,6 +200,102 @@ class SurveyInstanceAdmin(admin.ModelAdmin):
obj.get_status_display()
)
status_badge.short_description = 'Status'
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'
@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'
]
search_fields = [
'survey_instance__patient__mrn',
'survey_instance__patient__first_name',
'survey_instance__patient__last_name',
'ip_address', 'user_agent'
]
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',)
}),
)
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'
)
def survey_instance_link(self, obj):
"""Link to survey instance"""
url = f"/admin/surveys/surveyinstance/{obj.survey_instance.id}/change/"
return format_html('<a href="{}">{} - {}</a>', url, obj.survey_instance.survey_template.name, 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',
}
color = colors.get(obj.event_type, 'secondary')
return format_html(
'<span class="badge bg-{}">{}</span>',
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'
@admin.register(SurveyResponse)
@ -211,7 +321,7 @@ class SurveyResponseAdmin(admin.ModelAdmin):
'fields': ('numeric_value', 'text_value', 'choice_value')
}),
('Metadata', {
'fields': ('response_time_seconds', 'created_at', 'updated_at')
'fields': ('created_at', 'updated_at')
}),
)

Some files were not shown because too many files have changed in this diff Show More