Compare commits

...

9 Commits

167 changed files with 25515 additions and 3513 deletions

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)

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

View File

@ -35,8 +35,8 @@ class UserAdmin(BaseUserAdmin):
ordering = ['-date_joined']
fieldsets = (
(None, {'fields': ('email', 'username', 'password')}),
(_('Personal info'), {'fields': ('first_name', 'last_name', 'phone', 'employee_id')}),
(None, {'fields': ('email', 'password')}),
(_('Personal info'), {'fields': ('first_name', 'last_name', 'username', 'phone', 'employee_id')}),
(_('Organization'), {'fields': ('hospital', 'department')}),
(_('Profile'), {'fields': ('avatar', 'bio', 'language')}),
(_('Permissions'), {

View File

@ -1,144 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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)),
('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(choices=[('email', 'Email'), ('sms', 'SMS'), ('both', 'Both')], default='email', help_text='Preferred notification channel for general notifications', max_length=10)),
('explanation_notification_channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('both', 'Both')], default='email', help_text='Preferred channel for explanation requests', max_length=10)),
('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.1 on 2026-01-15 12:02
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

@ -1,14 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 11:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_user_explanation_notification_channel_and_more'),
('accounts', '0004_username_default'),
]
operations = [
]

View File

@ -1,43 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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

@ -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.1 on 2026-01-15 12:02
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

@ -1,50 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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,13 +37,13 @@ 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',
'assigned_to', 'created_at'
'created_by', 'assigned_to', 'created_at'
]
list_filter = [
'status', 'severity', 'priority', 'category', 'source',
'is_overdue', 'hospital', 'created_at'
'is_overdue', 'hospital', 'created_by', 'created_at'
]
search_fields = [
'title', 'description', 'patient__mrn',
@ -61,11 +61,14 @@ 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')
}),
('Creator Tracking', {
'fields': ('created_by',)
}),
('Status & Assignment', {
'fields': ('status', 'assigned_to', 'assigned_at')
}),
@ -94,7 +97,8 @@ class ComplaintAdmin(admin.ModelAdmin):
qs = super().get_queryset(request)
return qs.select_related(
'patient', 'hospital', 'department', 'staff',
'assigned_to', 'resolved_by', 'closed_by', 'resolution_survey'
'assigned_to', 'resolved_by', 'closed_by', 'resolution_survey',
'created_by'
)
def title_preview(self, obj):
@ -135,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:
@ -219,9 +237,9 @@ class InquiryAdmin(admin.ModelAdmin):
"""Inquiry admin"""
list_display = [
'subject_preview', 'patient', 'contact_name',
'hospital', 'category', 'status', 'assigned_to', 'created_at'
'hospital', 'category', 'status', 'created_by', 'assigned_to', 'created_at'
]
list_filter = ['status', 'category', 'source', 'hospital', 'created_at']
list_filter = ['status', 'category', 'source', 'hospital', 'created_by', 'created_at']
search_fields = [
'subject', 'message', 'contact_name', 'contact_phone',
'patient__mrn', 'patient__first_name', 'patient__last_name'
@ -242,6 +260,9 @@ class InquiryAdmin(admin.ModelAdmin):
('Inquiry Details', {
'fields': ('subject', 'message', 'category', 'source')
}),
('Creator Tracking', {
'fields': ('created_by',)
}),
('Status & Assignment', {
'fields': ('status', 'assigned_to')
}),
@ -259,7 +280,7 @@ class InquiryAdmin(admin.ModelAdmin):
qs = super().get_queryset(request)
return qs.select_related(
'patient', 'hospital', 'department',
'assigned_to', 'responded_by'
'assigned_to', 'responded_by', 'created_by'
)
def subject_preview(self, obj):

View File

@ -591,6 +591,343 @@ class ComplaintThresholdForm(forms.ModelForm):
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):
"""Public inquiry submission form (simpler, for general questions)"""

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,281 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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')),
('sla_due_at', models.DateTimeField(blank=True, db_index=True, help_text='SLA deadline for staff to submit explanation', null=True)),
('is_overdue', models.BooleanField(db_index=True, default=False, help_text='Explanation request is overdue')),
('reminder_sent_at', models.DateTimeField(blank=True, help_text='Reminder sent to staff about overdue explanation', null=True)),
('escalated_at', models.DateTimeField(blank=True, help_text='When explanation was escalated to manager', null=True)),
],
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 first reminder X hours before deadline')),
('second_reminder_enabled', models.BooleanField(default=False, help_text='Enable sending a second reminder')),
('second_reminder_hours_before', models.IntegerField(default=6, help_text='Send second reminder X hours before deadline')),
('thank_you_email_enabled', models.BooleanField(default=False, help_text='Send thank you email when complaint is closed')),
('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)),
('escalation_level', models.IntegerField(default=1, help_text='Escalation level (1 = first level, 2 = second, etc.)')),
('max_escalation_level', models.IntegerField(default=3, help_text='Maximum escalation level before stopping (default: 3)')),
('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)')),
('reminder_escalation_enabled', models.BooleanField(default=False, help_text='Enable escalation after reminder if no action taken')),
('reminder_escalation_hours', models.IntegerField(default=24, help_text='Escalate X hours after reminder if no action')),
('escalate_to_role', models.CharField(choices=[('department_manager', 'Department Manager'), ('hospital_admin', 'Hospital Admin'), ('px_admin', 'PX Admin'), ('ceo', 'CEO'), ('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='ExplanationSLAConfig',
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_hours', models.IntegerField(default=48, help_text='Hours staff has to submit explanation')),
('reminder_hours_before', models.IntegerField(default=12, help_text='Send reminder X hours before deadline')),
('auto_escalate_enabled', models.BooleanField(default=True, help_text='Automatically escalate to manager if no response')),
('escalation_hours_overdue', models.IntegerField(default=0, help_text='Escalate X hours after overdue (0 = immediately)')),
('max_escalation_levels', models.IntegerField(default=3, help_text='Maximum levels to escalate up staff hierarchy')),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'Explanation SLA Config',
'verbose_name_plural': 'Explanation SLA Configs',
'ordering': ['hospital'],
},
),
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, help_text='First SLA reminder timestamp', null=True)),
('second_reminder_sent_at', models.DateTimeField(blank=True, help_text='Second SLA reminder timestamp', 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.1 on 2026-01-15 12:02
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,233 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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='escalated_to_manager',
field=models.ForeignKey(blank=True, help_text="Escalated to this explanation (manager's explanation request)", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalated_from_staff', to='complaints.complaintexplanation'),
),
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='explanationslaconfig',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explanation_sla_configs', to='organizations.hospital'),
),
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='explanationslaconfig',
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_fe4ec5_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'),
),
]

View File

@ -1,14 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 11:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('complaints', '0004_complaint_created_by_inquiry_created_by_and_more'),
('complaints', '0005_complaintexplanation_escalated_at_and_more'),
]
operations = [
]

View File

@ -28,6 +28,13 @@ class ComplaintStatus(models.TextChoices):
CANCELLED = "cancelled", "Cancelled"
class ComplaintType(models.TextChoices):
"""Complaint type choices - distinguish between complaints and appreciations"""
COMPLAINT = "complaint", "Complaint"
APPRECIATION = "appreciation", "Appreciation"
class ComplaintSource(models.TextChoices):
"""Complaint source choices"""
@ -154,6 +161,15 @@ class Complaint(UUIDModel, TimeStampedModel):
)
subcategory = models.CharField(max_length=100, blank=True)
# Type (complaint vs appreciation)
complaint_type = models.CharField(
max_length=20,
choices=ComplaintType.choices,
default=ComplaintType.COMPLAINT,
db_index=True,
help_text="Type of feedback (complaint vs appreciation)"
)
# Priority and severity
priority = models.CharField(
max_length=20, choices=PriorityChoices.choices, default=PriorityChoices.MEDIUM, db_index=True
@ -169,7 +185,7 @@ class Complaint(UUIDModel, TimeStampedModel):
related_name="complaints",
null=True,
blank=True,
help_text="Source of complaint"
help_text="Source of complaint",
)
# Creator tracking
@ -235,9 +251,19 @@ class Complaint(UUIDModel, TimeStampedModel):
return f"{self.title} - ({self.status})"
def save(self, *args, **kwargs):
"""Calculate SLA due date on creation"""
"""Calculate SLA due date on creation and sync complaint_type from metadata"""
if not self.due_at:
self.due_at = self.calculate_sla_due_date()
# Sync complaint_type from AI metadata if not already set
# This ensures the model field stays in sync with AI classification
if self.metadata and 'ai_analysis' in self.metadata:
ai_complaint_type = self.metadata['ai_analysis'].get('complaint_type', 'complaint')
# Only sync if model field is still default 'complaint'
# This preserves any manual changes while fixing AI-synced complaints
if self.complaint_type == 'complaint' and ai_complaint_type != 'complaint':
self.complaint_type = ai_complaint_type
super().save(*args, **kwargs)
def calculate_sla_due_date(self):
@ -763,16 +789,6 @@ class Inquiry(UUIDModel, TimeStampedModel):
help_text="Source of inquiry",
)
# Creator tracking
created_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_inquiries',
help_text="User who created this inquiry (SourceUser or Patient)"
)
# Status
status = models.CharField(
max_length=20,
@ -786,6 +802,16 @@ class Inquiry(UUIDModel, TimeStampedModel):
db_index=True,
)
# Creator tracking
created_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_inquiries',
help_text="User who created this inquiry (SourceUser or Patient)"
)
# Assignment
assigned_to = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_inquiries"

View File

@ -19,7 +19,7 @@ from django.utils import timezone
logger = logging.getLogger(__name__)
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None, return_all: bool = False, fuzzy_threshold: float = 0.65) -> Tuple[list, float, str]:
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None, return_all: bool = False, fuzzy_threshold: float = 0.80, max_matches: int = 3) -> Tuple[list, float, str]:
"""
Enhanced staff matching with fuzzy matching and improved accuracy.
@ -29,13 +29,15 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
- Typos and minor errors
- Matching against original full name field
- Better confidence scoring
- STRICTER matching to avoid false positives
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)
fuzzy_threshold: Minimum similarity ratio for fuzzy matches (0.0 to 1.0). Default 0.80 for stricter matching.
max_matches: Maximum number of matches to return when return_all=True. Default 3.
Returns:
If return_all=True: Tuple of (matches_list, confidence_score, matching_method)
@ -80,7 +82,7 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
# 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]
dept_staff = [s for s in all_staff if s.department if str(s.department.id) == dept_id]
else:
dept_staff = []
@ -156,11 +158,11 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
best_ratio = ratio
best_match_name = combo
# If good fuzzy match found
# If good fuzzy match found with STRONGER threshold
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
dept_bonus = 0.10 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0
confidence = best_ratio * 0.90 + dept_bonus # Higher confidence for better fuzzy matches
method = f"Fuzzy match ({best_ratio:.2f}) on '{best_match_name}'"
@ -169,7 +171,7 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
logger.info(f"FUZZY MATCH ({best_ratio:.2f}): {best_match_name} ~ {staff_name}")
# ========================================
# LAYER 3: PARTIAL/WORD MATCHES
# LAYER 3: PARTIAL/WORD MATCHES (only if still no matches and exact/partial match is high)
# ========================================
if not matches:
@ -178,41 +180,43 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
# 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 ""
]
# Require at least some words to match
if len(input_words) >= 2:
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)
# 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
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
# STRONGER requirement: At least 80% of words must match
if (match_count / total_words) >= 0.8 and match_count >= 2:
confidence = 0.70 + (match_count / total_words) * 0.10
dept_bonus = 0.10 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)"
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}")
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
@ -221,11 +225,16 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
if matches:
# Sort by confidence (descending)
matches.sort(key=lambda x: x['confidence'], reverse=True)
# Limit number of matches if return_all
if return_all and len(matches) > max_matches:
matches = matches[:max_matches]
best_confidence = matches[0]['confidence']
best_method = matches[0]['matching_method']
logger.info(
f"Returning {len(matches)} match(es) for '{staff_name}'. "
f"Returning {len(matches)} match(es) for '{staff_name}' (max: {max_matches}). "
f"Best: {matches[0]['name_en']} (confidence: {best_confidence:.2f}, method: {best_method})"
)
@ -1009,7 +1018,7 @@ def analyze_complaint_with_ai(complaint_id):
if complaint.category:
category_name = complaint.category.name_en
# Analyze complaint using AI service
# Analyze complaint using AI service
try:
analysis = AIService.analyze_complaint(
title=complaint.title,
@ -1018,6 +1027,9 @@ def analyze_complaint_with_ai(complaint_id):
hospital_id=complaint.hospital.id
)
# Get complaint type from analysis
complaint_type = analysis.get('complaint_type', 'complaint')
# Analyze emotion using AI service
emotion_analysis = AIService.analyze_emotion(
text=complaint.description
@ -1172,6 +1184,12 @@ def analyze_complaint_with_ai(complaint_id):
logger.info("No staff names extracted from complaint")
needs_staff_review = False
# Update complaint type from AI analysis
complaint.complaint_type = complaint_type
# Skip SLA and PX Actions for appreciations
is_appreciation = complaint_type == 'appreciation'
# Save reasoning in metadata
# Use JSON-serializable values instead of model objects
old_category_name = old_category.name_en if old_category else None
@ -1187,6 +1205,7 @@ def analyze_complaint_with_ai(complaint_id):
# Update or create ai_analysis in metadata with bilingual support and emotion
complaint.metadata['ai_analysis'] = {
'complaint_type': complaint_type,
'title_en': analysis.get('title_en', ''),
'title_ar': analysis.get('title_ar', ''),
'short_description_en': analysis.get('short_description_en', ''),
@ -1217,11 +1236,12 @@ def analyze_complaint_with_ai(complaint_id):
'staff_match_count': len(all_staff_matches)
}
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
complaint.save(update_fields=['complaint_type', 'severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
# Re-calculate SLA due date based on new severity
complaint.due_at = complaint.calculate_sla_due_date()
complaint.save(update_fields=['due_at'])
# Re-calculate SLA due date based on new severity (skip for appreciations)
if not is_appreciation:
complaint.due_at = complaint.calculate_sla_due_date()
complaint.save(update_fields=['due_at'])
# Create timeline update for AI completion
from apps.complaints.models import ComplaintUpdate
@ -1251,87 +1271,99 @@ def analyze_complaint_with_ai(complaint_id):
message=message
)
# PX Action creation is now MANDATORY for all complaints
# Initialize action_id
action_id = None
try:
logger.info(f"Creating PX Action for complaint {complaint_id} (Mandatory for all complaints)")
# Generate PX Action data using AI
action_data = AIService.create_px_action_from_complaint(complaint)
# Create PX Action object
from apps.px_action_center.models import PXAction, PXActionLog
from django.contrib.contenttypes.models import ContentType
complaint_ct = ContentType.objects.get_for_model(Complaint)
action = PXAction.objects.create(
source_type='complaint',
content_type=complaint_ct,
object_id=complaint.id,
title=action_data['title'],
description=action_data['description'],
hospital=complaint.hospital,
department=complaint.department,
category=action_data['category'],
priority=action_data['priority'],
severity=action_data['severity'],
status='open',
metadata={
'source_complaint_id': str(complaint.id),
'source_complaint_title': complaint.title,
'ai_generated': True,
'auto_created': True,
'ai_reasoning': action_data.get('reasoning', '')
}
)
action_id = str(action.id)
# Create action log entry
PXActionLog.objects.create(
action=action,
log_type='note',
message=f"Action automatically generated by AI for complaint: {complaint.title}",
metadata={
'complaint_id': str(complaint.id),
'ai_generated': True,
'auto_created': True,
'category': action_data['category'],
'priority': action_data['priority'],
'severity': action_data['severity']
}
)
# Create complaint update
# Skip PX Action creation for appreciations
if is_appreciation:
logger.info(f"Skipping PX Action creation for appreciation {complaint_id}")
# Create timeline entry for appreciation
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='note',
message=f"PX Action automatically created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}",
metadata={'action_id': str(action.id), 'category': action_data['category']}
message=f"Appreciation detected - No PX Action or SLA tracking required for positive feedback."
)
else:
# PX Action creation is MANDATORY for complaints
try:
logger.info(f"Creating PX Action for complaint {complaint_id}")
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='px_action_auto_created',
description=f"PX Action automatically created from AI analysis for complaint: {complaint.title}",
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'category': action_data['category'],
'priority': action_data['priority'],
'severity': action_data['severity'],
'ai_reasoning': action_data.get('reasoning', '')
}
)
# Generate PX Action data using AI
action_data = AIService.create_px_action_from_complaint(complaint)
logger.info(f"PX Action {action.id} automatically created for complaint {complaint_id}")
# Create PX Action object
from apps.px_action_center.models import PXAction, PXActionLog
from django.contrib.contenttypes.models import ContentType
except Exception as e:
logger.error(f"Error auto-creating PX Action for complaint {complaint_id}: {str(e)}", exc_info=True)
# Don't fail the entire task if PX Action creation fails
action_id = None
complaint_ct = ContentType.objects.get_for_model(Complaint)
action = PXAction.objects.create(
source_type='complaint',
content_type=complaint_ct,
object_id=complaint.id,
title=action_data['title'],
description=action_data['description'],
hospital=complaint.hospital,
department=complaint.department,
category=action_data['category'],
priority=action_data['priority'],
severity=action_data['severity'],
status='open',
metadata={
'source_complaint_id': str(complaint.id),
'source_complaint_title': complaint.title,
'ai_generated': True,
'auto_created': True,
'ai_reasoning': action_data.get('reasoning', '')
}
)
action_id = str(action.id)
# Create action log entry
PXActionLog.objects.create(
action=action,
log_type='note',
message=f"Action automatically generated by AI for complaint: {complaint.title}",
metadata={
'complaint_id': str(complaint.id),
'ai_generated': True,
'auto_created': True,
'category': action_data['category'],
'priority': action_data['priority'],
'severity': action_data['severity']
}
)
# Create complaint update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='note',
message=f"PX Action automatically created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}",
metadata={'action_id': str(action.id), 'category': action_data['category']}
)
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='px_action_auto_created',
description=f"PX Action automatically created from AI analysis for complaint: {complaint.title}",
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'category': action_data['category'],
'priority': action_data['priority'],
'severity': action_data['severity'],
'ai_reasoning': action_data.get('reasoning', '')
}
)
logger.info(f"PX Action {action.id} automatically created for complaint {complaint_id}")
except Exception as e:
logger.error(f"Error auto-creating PX Action for complaint {complaint_id}: {str(e)}", exc_info=True)
# Don't fail the entire task if PX Action creation fails
action_id = None
logger.info(
f"AI analysis complete for complaint {complaint_id}: "
@ -1833,8 +1865,8 @@ def send_sla_reminders():
active_complaints = Complaint.objects.filter(
status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]
).filter(
models.Q(reminder_sent_at__isnull=True) | # First reminder not sent
models.Q(
Q(reminder_sent_at__isnull=True) | # First reminder not sent
Q(
reminder_sent_at__isnull=False,
second_reminder_sent_at__isnull=True,
reminder_sent_at__lt=now - timezone.timedelta(hours=1) # At least 1 hour after first reminder

View File

@ -2,7 +2,6 @@
Complaints UI views - Server-rendered templates for complaints console
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@ -16,6 +15,7 @@ from apps.accounts.models import User
from apps.core.services import AuditService
from apps.organizations.models import Department, Hospital, Staff
from apps.px_sources.models import SourceUser, PXSource
from apps.px_sources.models import SourceUser, PXSource
from .models import (
Complaint,
@ -263,6 +263,19 @@ def complaint_detail(request, pk):
"explanation": explanation,
"explanations": explanations,
"explanation_attachments": explanation_attachments,
'base_layout': base_layout,
'source_user': source_user,
"complaint": complaint,
"timeline": timeline,
"attachments": attachments,
"px_actions": px_actions,
"assignable_users": assignable_users,
"status_choices": ComplaintStatus.choices,
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
"hospital_departments": hospital_departments,
"explanation": explanation,
"explanations": explanations,
"explanation_attachments": explanation_attachments,
}
return render(request, "complaints/complaint_detail.html", context)

View File

@ -1290,6 +1290,181 @@ This is an automated message from PX360 Complaint Management System.
'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):
"""ViewSet for Complaint Attachments"""

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',
@ -332,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
@ -779,5 +784,87 @@ class AIService:
# 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.1 on 2026-01-15 12:02
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

@ -1,96 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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

@ -1,154 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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

@ -114,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

@ -407,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,151 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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='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='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, help_text='Hospital configuration settings')),
('ceo', models.ForeignKey(blank=True, help_text='Chief Executive Officer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hospitals_as_ceo', to=settings.AUTH_USER_MODEL, verbose_name='CEO')),
('cfo', models.ForeignKey(blank=True, help_text='Chief Financial Officer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hospitals_as_cfo', to=settings.AUTH_USER_MODEL, verbose_name='CFO')),
('coo', models.ForeignKey(blank=True, help_text='Chief Operating Officer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hospitals_as_coo', to=settings.AUTH_USER_MODEL, verbose_name='COO')),
('medical_director', models.ForeignKey(blank=True, help_text='Medical Director', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hospitals_as_medical_director', to=settings.AUTH_USER_MODEL, verbose_name='Medical Director')),
('organization', 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')),
],
options={
'verbose_name_plural': 'Hospitals',
'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.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)),
('phone', models.CharField(blank=True, max_length=20, verbose_name='Phone Number')),
('employee_id', models.CharField(db_index=True, max_length=50, unique=True)),
('name', models.CharField(blank=True, max_length=300, verbose_name='Full Name (Original)')),
('country', models.CharField(blank=True, max_length=100, verbose_name='Country')),
('location', models.CharField(blank=True, max_length=200, verbose_name='Location')),
('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], max_length=10)),
('department_name', models.CharField(blank=True, max_length=200, verbose_name='Department (Original)')),
('section', models.CharField(blank=True, max_length=200, verbose_name='Section')),
('subsection', models.CharField(blank=True, max_length=200, verbose_name='Subsection')),
('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')),
('report_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='direct_reports', to='organizations.staff', verbose_name='Reports To')),
('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

@ -137,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,
@ -182,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)}')

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(

View File

@ -1,42 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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,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)

View File

@ -2,10 +2,11 @@
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/health - GET - Health check
- /api/simulator/reset - GET - Reset simulator
- /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
@ -19,6 +20,9 @@ urlpatterns = [
# 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'),

View File

@ -1,19 +1,35 @@
"""
Simulator views for testing external notification APIs.
This module provides API endpoints that simulate external email and SMS services:
- Email simulator: Sends real emails via Django SMTP
- SMS simulator: Prints messages to terminal with formatted output
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
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
@ -320,7 +336,7 @@ def health_check(request):
def reset_simulator(request):
"""
Reset simulator statistics and history.
Clears request counter and history.
"""
global request_counter, request_history
@ -333,3 +349,323 @@ def reset_simulator(request):
'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.1 on 2026-01-15 12:02
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.1 on 2026-01-15 12:02
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')
}),
)

375
apps/surveys/analytics.py Normal file
View File

@ -0,0 +1,375 @@
"""
Survey analytics and tracking utilities.
This module provides functions to calculate survey engagement metrics:
- Open rate
- Completion rate
- Abandonment rate
- Time to complete
- And other engagement metrics
"""
from django.db.models import Avg, Count, F, Q, Sum
from django.utils import timezone
from django.db.models.functions import TruncDay, TruncHour
from .models import SurveyInstance, SurveyTracking
def get_survey_engagement_stats(survey_template_id=None, hospital_id=None, days=30):
"""
Get comprehensive survey engagement statistics.
Args:
survey_template_id: Filter by specific survey template (optional)
hospital_id: Filter by hospital (optional)
days: Number of days to look back (default 30)
Returns:
dict: Engagement statistics
"""
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=days)
queryset = SurveyInstance.objects.filter(created_at__gte=cutoff_date)
if survey_template_id:
queryset = queryset.filter(survey_template_id=survey_template_id)
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
total_sent = queryset.count()
total_opened = queryset.filter(opened_at__isnull=False).count()
total_completed = queryset.filter(status='completed').count()
total_abandoned = queryset.filter(
opened_at__isnull=False,
status__in=['viewed', 'in_progress', 'abandoned']
).count()
# Calculate rates
open_rate = (total_opened / total_sent * 100) if total_sent > 0 else 0
completion_rate = (total_completed / total_sent * 100) if total_sent > 0 else 0
abandonment_rate = (total_abandoned / total_opened * 100) if total_opened > 0 else 0
# Calculate average time to complete (in minutes)
completed_surveys = queryset.filter(
status='completed',
sent_at__isnull=False,
completed_at__isnull=False
).annotate(
time_diff=F('completed_at') - F('sent_at')
)
avg_time_minutes = 0
if completed_surveys.exists():
total_seconds = sum(
s.time_diff.total_seconds() for s in completed_surveys if s.time_diff
)
avg_time_minutes = total_seconds / completed_surveys.count() / 60
return {
'total_sent': total_sent,
'total_opened': total_opened,
'total_completed': total_completed,
'total_abandoned': total_abandoned,
'open_rate': round(open_rate, 2),
'completion_rate': round(completion_rate, 2),
'abandonment_rate': round(abandonment_rate, 2),
'avg_time_to_complete_minutes': round(avg_time_minutes, 2),
}
def get_patient_survey_timeline(patient_id):
"""
Get timeline of surveys for a specific patient.
Args:
patient_id: Patient ID
Returns:
list: Survey timeline with metrics
"""
surveys = SurveyInstance.objects.filter(
patient_id=patient_id
).select_related(
'survey_template'
).order_by('-sent_at')
timeline = []
for survey in surveys:
# Calculate time to complete
time_to_complete = None
if survey.sent_at and survey.completed_at:
time_to_complete = (survey.completed_at - survey.sent_at).total_seconds()
elif survey.sent_at and survey.opened_at and not survey.completed_at:
# Time since last activity for in-progress surveys
time_to_complete = (timezone.now() - survey.opened_at).total_seconds()
timeline.append({
'survey_id': str(survey.id),
'survey_name': survey.survey_template.name,
'survey_type': survey.survey_template.survey_type,
'sent_at': survey.sent_at,
'opened_at': survey.opened_at,
'completed_at': survey.completed_at,
'status': survey.status,
'time_spent_seconds': survey.time_spent_seconds,
'time_to_complete_seconds': time_to_complete,
'open_count': getattr(survey, 'open_count', 0),
'total_score': float(survey.total_score) if survey.total_score else None,
'is_negative': survey.is_negative,
'delivery_channel': survey.delivery_channel,
})
return timeline
def get_survey_completion_times(survey_template_id=None, hospital_id=None, days=30):
"""
Get individual survey completion times.
Args:
survey_template_id: Filter by survey template (optional)
hospital_id: Filter by hospital (optional)
days: Number of days to look back (default 30)
Returns:
list: Survey completion times
"""
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=days)
queryset = SurveyInstance.objects.filter(
status='completed',
sent_at__isnull=False,
completed_at__isnull=False,
created_at__gte=cutoff_date
).select_related(
'patient',
'survey_template'
).annotate(
time_to_complete=F('completed_at') - F('sent_at')
)
if survey_template_id:
queryset = queryset.filter(survey_template_id=survey_template_id)
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
completion_times = []
for survey in queryset:
if survey.time_to_complete:
completion_times.append({
'survey_id': str(survey.id),
'patient_name': survey.patient.get_full_name(),
'survey_name': survey.survey_template.name,
'sent_at': survey.sent_at,
'completed_at': survey.completed_at,
'time_to_complete_minutes': survey.time_to_complete.total_seconds() / 60,
'time_spent_seconds': survey.time_spent_seconds,
'total_score': float(survey.total_score) if survey.total_score else None,
'is_negative': survey.is_negative,
})
# Sort by time to complete
completion_times.sort(key=lambda x: x['time_to_complete_minutes'])
return completion_times
def get_survey_abandonment_analysis(survey_template_id=None, hospital_id=None, days=30):
"""
Analyze abandoned surveys to identify patterns.
Args:
survey_template_id: Filter by survey template (optional)
hospital_id: Filter by hospital (optional)
days: Number of days to look back (default 30)
Returns:
dict: Abandonment analysis
"""
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=days)
# Get abandoned surveys
abandoned_queryset = SurveyInstance.objects.filter(
opened_at__isnull=False,
status__in=['viewed', 'in_progress', 'abandoned'],
created_at__gte=cutoff_date
).select_related(
'survey_template',
'patient'
)
if survey_template_id:
abandoned_queryset = abandoned_queryset.filter(survey_template_id=survey_template_id)
if hospital_id:
abandoned_queryset = abandoned_queryset.filter(hospital_id=hospital_id)
# Analyze abandonment by channel
by_channel = {}
for survey in abandoned_queryset:
channel = survey.delivery_channel
if channel not in by_channel:
by_channel[channel] = 0
by_channel[channel] += 1
# Analyze abandonment by template
by_template = {}
for survey in abandoned_queryset:
template_name = survey.survey_template.name
if template_name not in by_template:
by_template[template_name] = 0
by_template[template_name] += 1
# Calculate time until abandonment
abandonment_times = []
for survey in abandoned_queryset:
if survey.opened_at:
time_abandoned = (timezone.now() - survey.opened_at).total_seconds()
abandonment_times.append(time_abandoned)
avg_abandonment_time_minutes = 0
if abandonment_times:
avg_abandonment_time_minutes = sum(abandonment_times) / len(abandonment_times) / 60
return {
'total_abandoned': abandoned_queryset.count(),
'by_channel': by_channel,
'by_template': by_template,
'avg_time_until_abandonment_minutes': round(avg_abandonment_time_minutes, 2),
}
def get_hourly_survey_activity(hospital_id=None, days=7):
"""
Get survey activity by hour.
Args:
hospital_id: Filter by hospital (optional)
days: Number of days to look back (default 7)
Returns:
list: Hourly activity data
"""
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=days)
queryset = SurveyInstance.objects.filter(created_at__gte=cutoff_date)
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
# Annotate with hour
activity = queryset.annotate(
hour=TruncHour('created_at')
).values('hour', 'status').annotate(
count=Count('id')
).order_by('hour')
return list(activity)
def track_survey_open(survey_instance):
"""
Track when a survey is opened.
This should be called every time a survey is accessed.
Args:
survey_instance: SurveyInstance
Returns:
SurveyTracking: Created tracking event
"""
# Increment open count
if not hasattr(survey_instance, 'open_count'):
survey_instance.open_count = 0
survey_instance.open_count += 1
survey_instance.last_opened_at = timezone.now()
# Update status if first open
if not survey_instance.opened_at:
survey_instance.opened_at = timezone.now()
survey_instance.status = 'viewed'
survey_instance.save(update_fields=['open_count', 'last_opened_at', 'opened_at', 'status'])
# Create tracking event
tracking = SurveyTracking.objects.create(
survey_instance=survey_instance,
event_type='page_view',
user_agent='', # Will be filled by view
ip_address='', # Will be filled by view
)
return tracking
def track_survey_completion(survey_instance):
"""
Track when a survey is completed.
Args:
survey_instance: SurveyInstance
Returns:
SurveyTracking: Created tracking event
"""
# Calculate time spent
time_spent = None
if survey_instance.opened_at and survey_instance.completed_at:
time_spent = (survey_instance.completed_at - survey_instance.opened_at).total_seconds()
# Update survey instance
survey_instance.time_spent_seconds = int(time_spent) if time_spent else None
survey_instance.save(update_fields=['time_spent_seconds'])
# Create tracking event
tracking = SurveyTracking.objects.create(
survey_instance=survey_instance,
event_type='survey_completed',
total_time_spent=int(time_spent) if time_spent else None,
)
return tracking
def track_survey_abandonment(survey_instance):
"""
Track when a survey is abandoned (started but not completed).
Args:
survey_instance: SurveyInstance
Returns:
SurveyTracking: Created tracking event
"""
# Calculate time until abandonment
time_abandoned = None
if survey_instance.opened_at:
time_abandoned = (timezone.now() - survey_instance.opened_at).total_seconds()
# Update status
survey_instance.status = 'abandoned'
survey_instance.save(update_fields=['status'])
# Create tracking event
tracking = SurveyTracking.objects.create(
survey_instance=survey_instance,
event_type='survey_abandoned',
total_time_spent=int(time_abandoned) if time_abandoned else None,
)
return tracking

View File

@ -0,0 +1,268 @@
"""
Survey analytics API views.
Provides endpoints for accessing survey engagement metrics:
- Engagement statistics
- Patient survey timelines
- Completion times
- Abandonment analysis
- Hourly activity
"""
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .analytics import (
get_survey_engagement_stats,
get_patient_survey_timeline,
get_survey_completion_times,
get_survey_abandonment_analysis,
get_hourly_survey_activity,
)
from .models import SurveyInstance, SurveyTracking
from .serializers import SurveyInstanceSerializer, SurveyTrackingSerializer
class SurveyAnalyticsViewSet(viewsets.ViewSet):
"""
Survey analytics API.
Provides various metrics about survey engagement:
- Engagement rates (open, completion, abandonment)
- Time metrics (time to complete, time spent)
- Patient timelines
- Abandonment patterns
- Hourly activity
"""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def engagement_stats(self, request):
"""
Get overall survey engagement statistics.
Query params:
- survey_template_id: Filter by survey template (optional)
- hospital_id: Filter by hospital (optional)
- days: Number of days to look back (default: 30)
"""
survey_template_id = request.query_params.get('survey_template_id')
hospital_id = request.query_params.get('hospital_id')
days = int(request.query_params.get('days', 30))
try:
stats = get_survey_engagement_stats(
survey_template_id=survey_template_id,
hospital_id=hospital_id,
days=days
)
return Response(stats)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'])
def patient_timeline(self, request):
"""
Get survey timeline for a specific patient.
Query params:
- patient_id: Required - Patient ID
"""
patient_id = request.query_params.get('patient_id')
if not patient_id:
return Response(
{'error': 'patient_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
timeline = get_patient_survey_timeline(patient_id)
return Response({'timeline': timeline})
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'])
def completion_times(self, request):
"""
Get individual survey completion times.
Query params:
- survey_template_id: Filter by survey template (optional)
- hospital_id: Filter by hospital (optional)
- days: Number of days to look back (default: 30)
"""
survey_template_id = request.query_params.get('survey_template_id')
hospital_id = request.query_params.get('hospital_id')
days = int(request.query_params.get('days', 30))
try:
completion_times = get_survey_completion_times(
survey_template_id=survey_template_id,
hospital_id=hospital_id,
days=days
)
return Response({'completion_times': completion_times})
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'])
def abandonment_analysis(self, request):
"""
Analyze abandoned surveys to identify patterns.
Query params:
- survey_template_id: Filter by survey template (optional)
- hospital_id: Filter by hospital (optional)
- days: Number of days to look back (default: 30)
"""
survey_template_id = request.query_params.get('survey_template_id')
hospital_id = request.query_params.get('hospital_id')
days = int(request.query_params.get('days', 30))
try:
analysis = get_survey_abandonment_analysis(
survey_template_id=survey_template_id,
hospital_id=hospital_id,
days=days
)
return Response(analysis)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'])
def hourly_activity(self, request):
"""
Get survey activity by hour.
Query params:
- hospital_id: Filter by hospital (optional)
- days: Number of days to look back (default: 7)
"""
hospital_id = request.query_params.get('hospital_id')
days = int(request.query_params.get('days', 7))
try:
activity = get_hourly_survey_activity(
hospital_id=hospital_id,
days=days
)
return Response({'activity': activity})
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'])
def summary_dashboard(self, request):
"""
Get a comprehensive summary dashboard with all key metrics.
Query params:
- hospital_id: Filter by hospital (optional)
- days: Number of days to look back (default: 30)
"""
hospital_id = request.query_params.get('hospital_id')
days = int(request.query_params.get('days', 30))
try:
# Get engagement stats
engagement = get_survey_engagement_stats(
hospital_id=hospital_id,
days=days
)
# Get abandonment analysis
abandonment = get_survey_abandonment_analysis(
hospital_id=hospital_id,
days=days
)
# Get top 10 fastest completions
fastest_completions = get_survey_completion_times(
hospital_id=hospital_id,
days=days
)[:10]
# Get top 10 slowest completions
slowest_completions = get_survey_completion_times(
hospital_id=hospital_id,
days=days
)[-10:] if len(get_survey_completion_times(hospital_id=hospital_id, days=days)) > 10 else []
return Response({
'engagement': engagement,
'abandonment': abandonment,
'fastest_completions': fastest_completions,
'slowest_completions': slowest_completions,
})
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
class SurveyTrackingViewSet(viewsets.ReadOnlyModelViewSet):
"""
Survey tracking events API.
View detailed tracking events for surveys:
- Page views
- Completions
- Abandonments
- Device/browser information
"""
queryset = SurveyTracking.objects.select_related(
'survey_instance',
'survey_instance__patient',
'survey_instance__survey_template'
).order_by('-created_at')
serializer_class = SurveyTrackingSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['survey_instance', 'event_type', 'device_type', 'browser']
search_fields = ['ip_address', 'user_agent']
@action(detail=False, methods=['get'])
def by_survey(self, request):
"""
Get tracking events for a specific survey instance.
Query params:
- survey_instance_id: Required - SurveyInstance ID
"""
survey_instance_id = request.query_params.get('survey_instance_id')
if not survey_instance_id:
return Response(
{'error': 'survey_instance_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
tracking_events = self.queryset.filter(
survey_instance_id=survey_instance_id
)
# Serialize and return
serializer = self.get_serializer(tracking_events, many=True)
return Response(serializer.data)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)

91
apps/surveys/forms.py Normal file
View File

@ -0,0 +1,91 @@
"""
Survey forms for CRUD operations
"""
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import SurveyTemplate, SurveyQuestion
class SurveyTemplateForm(forms.ModelForm):
"""Form for creating/editing survey templates"""
class Meta:
model = SurveyTemplate
fields = [
'name', 'name_ar', 'hospital', 'survey_type',
'scoring_method', 'negative_threshold', 'is_active'
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., MD Consultation Feedback'
}),
'name_ar': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'الاسم بالعربية'
}),
'hospital': forms.Select(attrs={'class': 'form-select'}),
'survey_type': forms.Select(attrs={'class': 'form-select'}),
'scoring_method': forms.Select(attrs={'class': 'form-select'}),
'negative_threshold': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.1',
'min': '1',
'max': '5'
}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
class SurveyQuestionForm(forms.ModelForm):
"""Form for creating/editing survey questions"""
class Meta:
model = SurveyQuestion
fields = [
'text', 'text_ar', 'question_type', 'order',
'is_required', 'choices_json'
]
widgets = {
'text': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Enter question in English'
}),
'text_ar': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'أدخل السؤال بالعربية'
}),
'question_type': forms.Select(attrs={'class': 'form-select'}),
'order': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0'
}),
'is_required': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'choices_json': forms.Textarea(attrs={
'class': 'form-control',
'rows': 5,
'placeholder': '[{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]'
}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['choices_json'].required = False
self.fields['choices_json'].help_text = _(
'JSON array of choices for multiple choice questions. '
'Format: [{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]'
)
SurveyQuestionFormSet = forms.inlineformset_factory(
SurveyTemplate,
SurveyQuestion,
form=SurveyQuestionForm,
extra=1,
can_delete=True,
min_num=1,
validate_min=True
)

View File

@ -0,0 +1,134 @@
from django.core.management.base import BaseCommand
from apps.surveys.models import SurveyTemplate, SurveyQuestion
from apps.organizations.models import Hospital
class Command(BaseCommand):
help = 'Create a demo survey template with different question types'
def handle(self, *args, **options):
# Get or create a hospital
hospital, _ = Hospital.objects.get_or_create(
code='DEMO',
defaults={
'name': "Al Hammadi Hospital - Demo",
'city': 'Riyadh',
'status': 'active'
}
)
# Create the survey template
template = SurveyTemplate.objects.create(
name="Patient Experience Demo Survey",
name_ar="استبيان تجربة المريض التجريبي",
hospital=hospital,
survey_type='stage',
scoring_method='average',
negative_threshold=3.0,
is_active=True
)
self.stdout.write(self.style.SUCCESS(f'✓ Created template: {template.name}'))
# Question 1: Text question
q1 = SurveyQuestion.objects.create(
survey_template=template,
text="Please share any additional comments about your stay",
text_ar="يرجى مشاركة أي تعليقات إضافية حول إقامتك",
question_type='text',
order=1,
is_required=False
)
self.stdout.write(f' ✓ Question 1: Text - {q1.text}')
# Question 2: Rating question
q2 = SurveyQuestion.objects.create(
survey_template=template,
text="How would you rate the quality of nursing care?",
text_ar="كيف تقي جودة التمريض؟",
question_type='rating',
order=2,
is_required=True
)
self.stdout.write(f' ✓ Question 2: Rating - {q2.text}')
# Question 3: Multiple choice question (single_choice doesn't exist)
q3 = SurveyQuestion.objects.create(
survey_template=template,
text="Which department did you visit?",
text_ar="ما هو القسم الذي زرته؟",
question_type='multiple_choice',
order=3,
is_required=True,
choices_json=[
{"value": "emergency", "label": "Emergency", "label_ar": "الطوارئ"},
{"value": "outpatient", "label": "Outpatient", "label_ar": "العيادات الخارجية"},
{"value": "inpatient", "label": "Inpatient", "label_ar": "الإقامة الداخلية"},
{"value": "surgery", "label": "Surgery", "label_ar": "الجراحة"},
{"value": "radiology", "label": "Radiology", "label_ar": "الأشعة"}
]
)
self.stdout.write(f' ✓ Question 3: Multiple Choice - {q3.text}')
self.stdout.write(f' Choices: {", ".join([c["label"] for c in q3.choices_json])}')
# Question 4: Another multiple choice question
q4 = SurveyQuestion.objects.create(
survey_template=template,
text="What aspects of your experience were satisfactory? (Select all that apply)",
text_ar="ما هي جوانب تجربتك التي كانت مرضية؟ (حدد جميع ما ينطبق)",
question_type='multiple_choice',
order=4,
is_required=False,
choices_json=[
{"value": "staff", "label": "Staff friendliness", "label_ar": "لطف الموظفين"},
{"value": "cleanliness", "label": "Cleanliness", "label_ar": "النظافة"},
{"value": "communication", "label": "Communication", "label_ar": "التواصل"},
{"value": "wait_times", "label": "Wait times", "label_ar": "أوقات الانتظار"},
{"value": "facilities", "label": "Facilities", "label_ar": "المرافق"}
]
)
self.stdout.write(f' ✓ Question 4: Multiple Choice - {q4.text}')
self.stdout.write(f' Choices: {", ".join([c["label"] for c in q4.choices_json])}')
# Question 5: Another rating question
q5 = SurveyQuestion.objects.create(
survey_template=template,
text="How would you rate the hospital facilities?",
text_ar="كيف تقي مرافق المستشفى؟",
question_type='rating',
order=5,
is_required=True
)
self.stdout.write(f' ✓ Question 5: Rating - {q5.text}')
self.stdout.write(self.style.SUCCESS('\n' + '='*70))
self.stdout.write(self.style.SUCCESS('DEMO SURVEY TEMPLATE CREATED SUCCESSFULLY!'))
self.stdout.write(self.style.SUCCESS('='*70))
self.stdout.write(f'\nTemplate ID: {template.id}')
self.stdout.write(f'Template Name: {template.name}')
self.stdout.write(f'Total Questions: {template.questions.count()}')
self.stdout.write('\nQuestion Types Summary:')
self.stdout.write(f' - Text questions: {template.questions.filter(question_type="text").count()}')
self.stdout.write(f' - Rating questions: {template.questions.filter(question_type="rating").count()}')
self.stdout.write(f' - Single Choice questions: {template.questions.filter(question_type="single_choice").count()}')
self.stdout.write(f' - Multiple Choice questions: {template.questions.filter(question_type="multiple_choice").count()}')
self.stdout.write(self.style.SUCCESS('\n' + '='*70))
self.stdout.write(self.style.SUCCESS('NEXT STEPS:'))
self.stdout.write(self.style.SUCCESS('='*70))
self.stdout.write('1. Open your browser and go to: http://localhost:8000/surveys/templates/')
self.stdout.write(f'2. Find and click on: {template.name}')
self.stdout.write('3. You\'ll see the survey builder with all questions')
self.stdout.write('4. The preview panel will show how each question type appears to patients')
self.stdout.write('\nPreview Guide:')
self.stdout.write(' ✓ Text: Shows as a textarea input')
self.stdout.write(' ✓ Rating: Shows 5 radio buttons (Poor to Excellent)')
self.stdout.write(' ✓ Single Choice: Shows radio buttons, only one can be selected')
self.stdout.write(' ✓ Multiple Choice: Shows checkboxes, multiple can be selected')
self.stdout.write('\nBilingual Support:')
self.stdout.write(' - All questions have both English and Arabic text')
self.stdout.write(' - Preview will show Arabic if you switch language')
self.stdout.write(self.style.SUCCESS('='*70))

View File

@ -0,0 +1,121 @@
"""
Management command to mark surveys as abandoned.
Marks surveys that have been opened or started but not completed
within a configurable time period as abandoned.
Usage:
python manage.py mark_abandoned_surveys
python manage.py mark_abandoned_surveys --hours 24
python manage.py mark_abandoned_surveys --dry-run
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.conf import settings
from django.utils.dateparse import parse_duration
from datetime import timedelta
from apps.surveys.models import SurveyInstance, SurveyTracking
class Command(BaseCommand):
help = 'Mark surveys as abandoned if not completed within specified time'
def add_arguments(self, parser):
parser.add_argument(
'--hours',
type=int,
default=getattr(settings, 'SURVEY_ABANDONMENT_HOURS', 24),
help='Hours after which to mark survey as abandoned (default: 24)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without making changes'
)
def handle(self, *args, **options):
hours = options['hours']
dry_run = options['dry_run']
self.stdout.write(self.style.SUCCESS(
f"{'[DRY RUN] ' if dry_run else ''}Marking surveys as abandoned (after {hours} hours)"
))
# Calculate cutoff time
cutoff_time = timezone.now() - timedelta(hours=hours)
# Find surveys that should be marked as abandoned
# Criteria:
# 1. Status is 'viewed' or 'in_progress'
# 2. Token hasn't expired
# 3. Last opened at least X hours ago
# 4. Not already abandoned, completed, expired, or cancelled
surveys_to_abandon = SurveyInstance.objects.filter(
status__in=['viewed', 'in_progress'],
token_expires_at__gt=timezone.now(), # Not expired
last_opened_at__lt=cutoff_time
).select_related('survey_template', 'patient')
count = surveys_to_abandon.count()
if count == 0:
self.stdout.write(self.style.WARNING('No surveys to mark as abandoned'))
return
self.stdout.write(f"Found {count} surveys to mark as abandoned:")
for survey in surveys_to_abandon:
time_since_open = timezone.now() - survey.last_opened_at
hours_since_open = time_since_open.total_seconds() / 3600
# Get question count for this survey
tracking_events = survey.tracking_events.filter(
event_type='question_answered'
).count()
self.stdout.write(
f" - {survey.survey_template.name} | "
f"Patient: {survey.patient.get_full_name()} | "
f"Status: {survey.status} | "
f"Opened: {survey.last_opened_at.strftime('%Y-%m-%d %H:%M')} | "
f"{hours_since_open:.1f} hours ago | "
f"Questions answered: {tracking_events}"
)
if dry_run:
self.stdout.write(self.style.WARNING(
f"\n[DRY RUN] Would mark {count} surveys as abandoned"
))
return
# Mark surveys as abandoned
updated = 0
for survey in surveys_to_abandon:
# Update status
survey.status = 'abandoned'
survey.save(update_fields=['status'])
# Track abandonment event
tracking_events = survey.tracking_events.filter(
event_type='question_answered'
)
SurveyTracking.objects.create(
survey_instance=survey,
event_type='survey_abandoned',
current_question=tracking_events.count(),
total_time_spent=survey.time_spent_seconds or 0,
metadata={
'time_since_open_hours': round(time_since_open.total_seconds() / 3600, 2),
'questions_answered': tracking_events.count(),
'original_status': survey.status,
}
)
updated += 1
self.stdout.write(self.style.SUCCESS(
f"\nSuccessfully marked {updated} surveys as abandoned"
))

View File

@ -1,137 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 12:02
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 = [
('journeys', '0001_initial'),
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SurveyTemplate',
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)')),
('description', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')),
('survey_type', models.CharField(choices=[('stage', 'Journey Stage Survey'), ('complaint_resolution', 'Complaint Resolution Satisfaction'), ('general', 'General Feedback'), ('nps', 'Net Promoter Score')], db_index=True, default='stage', max_length=50)),
('scoring_method', models.CharField(choices=[('average', 'Average Score'), ('weighted', 'Weighted Average'), ('nps', 'NPS Calculation')], default='average', max_length=20)),
('negative_threshold', models.DecimalField(decimal_places=1, default=3.0, help_text='Scores below this trigger PX actions (out of 5)', max_digits=3)),
('is_active', models.BooleanField(db_index=True, default=True)),
('version', models.IntegerField(default=1)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='survey_templates', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'name'],
},
),
migrations.CreateModel(
name='SurveyQuestion',
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)),
('text', models.TextField(verbose_name='Question Text (English)')),
('text_ar', models.TextField(blank=True, verbose_name='Question Text (Arabic)')),
('question_type', models.CharField(choices=[('rating', 'Rating (1-5 stars)'), ('nps', 'NPS (0-10)'), ('yes_no', 'Yes/No'), ('multiple_choice', 'Multiple Choice'), ('text', 'Text (Short Answer)'), ('textarea', 'Text Area (Long Answer)'), ('likert', 'Likert Scale (1-5)')], default='rating', max_length=20)),
('order', models.IntegerField(default=0, help_text='Display order')),
('is_required', models.BooleanField(default=True)),
('choices_json', models.JSONField(blank=True, default=list, help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]")),
('weight', models.DecimalField(decimal_places=2, default=1.0, help_text='Weight for weighted average scoring', max_digits=3)),
('branch_logic', models.JSONField(blank=True, default=dict, help_text="Conditional display logic: {'show_if': {'question_id': 'value'}}")),
('help_text', models.TextField(blank=True)),
('help_text_ar', models.TextField(blank=True)),
('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='surveys.surveytemplate')),
],
options={
'ordering': ['survey_template', 'order'],
},
),
migrations.CreateModel(
name='SurveyInstance',
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(blank=True, db_index=True, max_length=100)),
('delivery_channel', models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email')], default='sms', max_length=20)),
('recipient_phone', models.CharField(blank=True, max_length=20)),
('recipient_email', models.EmailField(blank=True, max_length=254)),
('access_token', models.CharField(blank=True, db_index=True, help_text='Secure token for survey access', max_length=100, unique=True)),
('token_expires_at', models.DateTimeField(blank=True, help_text='Token expiration date', null=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20)),
('sent_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('opened_at', models.DateTimeField(blank=True, null=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('total_score', models.DecimalField(blank=True, decimal_places=2, help_text='Calculated total score', max_digits=5, null=True)),
('is_negative', models.BooleanField(db_index=True, default=False, help_text='True if score below threshold')),
('metadata', models.JSONField(blank=True, default=dict)),
('patient_contacted', models.BooleanField(default=False, help_text='Whether patient was contacted about negative survey')),
('patient_contacted_at', models.DateTimeField(blank=True, null=True)),
('contact_notes', models.TextField(blank=True, help_text='Notes from patient contact')),
('issue_resolved', models.BooleanField(default=False, help_text='Whether the issue was resolved/explained')),
('satisfaction_feedback_sent', models.BooleanField(default=False, help_text='Whether satisfaction feedback form was sent')),
('satisfaction_feedback_sent_at', models.DateTimeField(blank=True, null=True)),
('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')),
('journey_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneyinstance')),
('journey_stage_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneystageinstance')),
('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='organizations.patient')),
('patient_contacted_by', models.ForeignKey(blank=True, help_text='User who contacted the patient', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacted_surveys', to=settings.AUTH_USER_MODEL)),
('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='surveys.surveytemplate')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SurveyResponse',
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)),
('numeric_value', models.DecimalField(blank=True, decimal_places=2, help_text='For rating, NPS, Likert questions', max_digits=10, null=True)),
('text_value', models.TextField(blank=True, help_text='For text, textarea questions')),
('choice_value', models.CharField(blank=True, help_text='For multiple choice questions', max_length=200)),
('response_time_seconds', models.IntegerField(blank=True, help_text='Time taken to answer this question', null=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='responses', to='surveys.surveyquestion')),
('survey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='surveys.surveyinstance')),
],
options={
'ordering': ['survey_instance', 'question__order'],
'unique_together': {('survey_instance', 'question')},
},
),
migrations.AddIndex(
model_name='surveytemplate',
index=models.Index(fields=['hospital', 'survey_type', 'is_active'], name='surveys_sur_hospita_0c8e30_idx'),
),
migrations.AddIndex(
model_name='surveyquestion',
index=models.Index(fields=['survey_template', 'order'], name='surveys_sur_survey__d8acd5_idx'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['patient', '-created_at'], name='surveys_sur_patient_7e68b1_idx'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['status', '-sent_at'], name='surveys_sur_status_ce377b_idx'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['is_negative', '-completed_at'], name='surveys_sur_is_nega_46c933_idx'),
),
]

View File

@ -17,6 +17,17 @@ from django.urls import reverse
from apps.core.models import BaseChoices, StatusChoices, TenantModel, TimeStampedModel, UUIDModel
class SurveyStatus(BaseChoices):
"""Survey status choices with enhanced tracking"""
SENT = 'sent', 'Sent (Not Opened)'
VIEWED = 'viewed', 'Viewed (Opened, Not Started)'
IN_PROGRESS = 'in_progress', 'In Progress (Started, Not Completed)'
COMPLETED = 'completed', 'Completed'
ABANDONED = 'abandoned', 'Abandoned (Started but Left)'
EXPIRED = 'expired', 'Expired'
CANCELLED = 'cancelled', 'Cancelled'
class QuestionType(BaseChoices):
"""Survey question type choices"""
RATING = 'rating', 'Rating (1-5 stars)'
@ -40,8 +51,6 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
"""
name = models.CharField(max_length=200)
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
description = models.TextField(blank=True)
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
# Configuration
hospital = models.ForeignKey(
@ -83,9 +92,6 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
# Configuration
is_active = models.BooleanField(default=True, db_index=True)
# Metadata
version = models.IntegerField(default=1)
class Meta:
ordering = ['hospital', 'name']
indexes = [
@ -136,25 +142,6 @@ class SurveyQuestion(UUIDModel, TimeStampedModel):
help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]"
)
# Scoring
weight = models.DecimalField(
max_digits=3,
decimal_places=2,
default=1.0,
help_text="Weight for weighted average scoring"
)
# Branch logic
branch_logic = models.JSONField(
default=dict,
blank=True,
help_text="Conditional display logic: {'show_if': {'question_id': 'value'}}"
)
# Help text
help_text = models.TextField(blank=True)
help_text_ar = models.TextField(blank=True)
class Meta:
ordering = ['survey_template', 'order']
indexes = [
@ -190,7 +177,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
related_name='surveys'
)
# Journey linkage (for stage surveys)
# Journey linkage
journey_instance = models.ForeignKey(
'journeys.PatientJourneyInstance',
on_delete=models.CASCADE,
@ -198,13 +185,6 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
blank=True,
related_name='surveys'
)
journey_stage_instance = models.ForeignKey(
'journeys.PatientJourneyStageInstance',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='surveys'
)
encounter_id = models.CharField(max_length=100, blank=True, db_index=True)
# Delivery
@ -237,8 +217,8 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
# Status
status = models.CharField(
max_length=20,
choices=StatusChoices.choices,
default=StatusChoices.PENDING,
choices=SurveyStatus.choices,
default=SurveyStatus.SENT,
db_index=True
)
@ -246,6 +226,22 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
opened_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
# Enhanced tracking
open_count = models.IntegerField(
default=0,
help_text="Number of times survey link was opened"
)
last_opened_at = models.DateTimeField(
null=True,
blank=True,
help_text="Most recent time survey was opened"
)
time_spent_seconds = models.IntegerField(
null=True,
blank=True,
help_text="Total time spent on survey in seconds"
)
# Scoring
total_score = models.DecimalField(
@ -287,13 +283,6 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
help_text="Whether the issue was resolved/explained"
)
# Satisfaction feedback tracking
satisfaction_feedback_sent = models.BooleanField(
default=False,
help_text="Whether satisfaction feedback form was sent"
)
satisfaction_feedback_sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
@ -322,8 +311,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
def get_survey_url(self):
"""Generate secure survey URL"""
# TODO: Implement in Phase 4 UI
return f"/surveys/{self.access_token}/"
return f"/surveys/s/{self.access_token}/"
def calculate_score(self):
"""
@ -348,14 +336,16 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
score = 0
elif self.survey_template.scoring_method == 'weighted':
# Weighted average based on question weights
total_weighted = 0
total_weight = 0
for response in responses:
if response.numeric_value and response.question.weight:
total_weighted += float(response.numeric_value) * float(response.question.weight)
total_weight += float(response.question.weight)
score = total_weighted / total_weight if total_weight > 0 else 0
# Simple average (weight feature removed)
rating_responses = responses.filter(
question__question_type__in=['rating', 'likert', 'nps']
)
if rating_responses.exists():
total = sum(float(r.numeric_value or 0) for r in rating_responses)
count = rating_responses.count()
score = total / count if count > 0 else 0
else:
score = 0
else: # NPS
# NPS calculation: % promoters - % detractors
@ -409,16 +399,104 @@ class SurveyResponse(UUIDModel, TimeStampedModel):
help_text="For multiple choice questions"
)
# Metadata
response_time_seconds = models.IntegerField(
null=True,
blank=True,
help_text="Time taken to answer this question"
)
class Meta:
ordering = ['survey_instance', 'question__order']
unique_together = [['survey_instance', 'question']]
class SurveyTracking(UUIDModel, TimeStampedModel):
"""
Detailed survey engagement tracking.
Tracks multiple interactions with survey:
- Page views
- Time spent on survey
- Abandonment events
- Device/browser information
"""
survey_instance = models.ForeignKey(
SurveyInstance,
on_delete=models.CASCADE,
related_name='tracking_events'
)
# Event type
event_type = models.CharField(
max_length=50,
choices=[
('page_view', 'Page View'),
('survey_started', 'Survey Started'),
('question_answered', 'Question Answered'),
('survey_abandoned', 'Survey Abandoned'),
('survey_completed', 'Survey Completed'),
('reminder_sent', 'Reminder Sent'),
],
db_index=True
)
# Timing
time_on_page = models.IntegerField(
null=True,
blank=True,
help_text="Time spent on page in seconds"
)
total_time_spent = models.IntegerField(
null=True,
blank=True,
help_text="Total time spent on survey so far in seconds"
)
# Context
current_question = models.IntegerField(
null=True,
blank=True,
help_text="Question number when event occurred"
)
# Device info
user_agent = models.TextField(blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
device_type = models.CharField(
max_length=50,
blank=True,
help_text="mobile, tablet, desktop"
)
browser = models.CharField(
max_length=100,
blank=True
)
# Location (optional, for analytics)
country = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True)
# Metadata
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['survey_instance', 'created_at']
indexes = [
models.Index(fields=['survey_instance', 'event_type', '-created_at']),
models.Index(fields=['event_type', '-created_at']),
]
def __str__(self):
return f"{self.survey_instance} - {self.question.text[:30]}"
return f"{self.survey_instance.id} - {self.event_type} at {self.created_at}"
@classmethod
def track_event(cls, survey_instance, event_type, **kwargs):
"""
Helper method to track a survey event.
Args:
survey_instance: SurveyInstance
event_type: str - event type key
**kwargs: additional fields (time_on_page, current_question, etc.)
Returns:
SurveyTracking instance
"""
return cls.objects.create(
survey_instance=survey_instance,
event_type=event_type,
**kwargs
)

View File

@ -2,13 +2,17 @@
Public survey views - Token-based survey forms (no login required)
"""
from django.contrib import messages
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_exempt
from user_agents import parse
from apps.core.services import AuditService
from .models import SurveyInstance, SurveyQuestion, SurveyResponse
from .models import SurveyInstance, SurveyQuestion, SurveyResponse, SurveyTracking
from .analytics import track_survey_open, track_survey_completion
@require_http_methods(["GET", "POST"])
@ -26,17 +30,17 @@ def survey_form(request, token):
- Form validation
"""
# Get survey instance by token
# Allow access until survey is completed or token expires (2 days by default)
try:
survey = SurveyInstance.objects.select_related(
'survey_template',
'patient',
'journey_instance',
'journey_stage_instance'
'journey_instance'
).prefetch_related(
'survey_template__questions'
).get(
access_token=token,
status__in=['pending', 'sent'],
status__in=['pending', 'sent', 'viewed', 'in_progress'],
token_expires_at__gt=timezone.now()
)
except SurveyInstance.DoesNotExist:
@ -44,11 +48,42 @@ def survey_form(request, token):
'error': 'invalid_or_expired'
})
# Mark as opened if first time
# Track survey open - increment count and record tracking event
# Get device info from user agent
user_agent_str = request.META.get('HTTP_USER_AGENT', '')
ip_address = request.META.get('REMOTE_ADDR', '')
# Parse user agent for device info
user_agent = parse(user_agent_str)
device_type = 'mobile' if user_agent.is_mobile else ('tablet' if user_agent.is_tablet else 'desktop')
browser = f"{user_agent.browser.family} {user_agent.browser.version_string}"
# Update survey instance tracking fields
survey.open_count += 1
survey.last_opened_at = timezone.now()
# Update status based on current state
if not survey.opened_at:
survey.opened_at = timezone.now()
survey.status = 'in_progress'
survey.save(update_fields=['opened_at', 'status'])
survey.status = 'viewed'
elif survey.status == 'sent':
survey.status = 'viewed'
survey.save(update_fields=['open_count', 'last_opened_at', 'opened_at', 'status'])
# Track page view event
SurveyTracking.track_event(
survey,
'page_view',
user_agent=user_agent_str[:500] if user_agent_str else '',
ip_address=ip_address,
device_type=device_type,
browser=browser,
metadata={
'referrer': request.META.get('HTTP_REFERER', ''),
'language': request.GET.get('lang', 'en'),
}
)
# Get questions
questions = survey.survey_template.questions.filter(
@ -150,13 +185,32 @@ def survey_form(request, token):
# Update survey status
survey.status = 'completed'
survey.completed_at = timezone.now()
survey.save(update_fields=['status', 'completed_at'])
# Calculate time spent (from opened_at to completed_at)
if survey.opened_at:
time_spent = (timezone.now() - survey.opened_at).total_seconds()
survey.time_spent_seconds = int(time_spent)
survey.save(update_fields=['status', 'completed_at', 'time_spent_seconds'])
# Track completion event
SurveyTracking.track_event(
survey,
'survey_completed',
total_time_spent=survey.time_spent_seconds,
user_agent=user_agent_str[:500] if user_agent_str else '',
ip_address=ip_address,
metadata={
'response_count': len(responses_data),
'language': language,
}
)
# Calculate score
score = survey.calculate_score()
# Log completion
AuditService.log(
AuditService.log_event(
event_type='survey_completed',
description=f"Survey completed: {survey.survey_template.name}",
user=None,
@ -218,3 +272,48 @@ def thank_you(request, token):
def invalid_token(request):
"""Invalid or expired token page"""
return render(request, 'surveys/invalid_token.html')
@csrf_exempt
@require_http_methods(["POST"])
def track_survey_start(request, token):
"""
API endpoint to track when patient starts answering survey.
Called via AJAX when patient first interacts with the form.
Updates status from 'viewed' to 'in_progress'.
"""
try:
# Get survey instance
survey = SurveyInstance.objects.get(
access_token=token,
status__in=['viewed', 'in_progress'],
token_expires_at__gt=timezone.now()
)
# Only update if not already in_progress
if survey.status == 'viewed':
survey.status = 'in_progress'
survey.save(update_fields=['status'])
# Track survey started event
SurveyTracking.track_event(
survey,
'survey_started',
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500] if request.META.get('HTTP_USER_AGENT') else '',
ip_address=request.META.get('REMOTE_ADDR', ''),
metadata={
'referrer': request.META.get('HTTP_REFERER', ''),
}
)
return JsonResponse({
'status': 'success',
'survey_status': survey.status,
})
except SurveyInstance.DoesNotExist:
return JsonResponse({
'status': 'error',
'message': 'Survey not found or invalid token'
}, status=404)

View File

@ -3,7 +3,7 @@ Surveys serializers
"""
from rest_framework import serializers
from .models import SurveyInstance, SurveyQuestion, SurveyResponse, SurveyTemplate
from .models import SurveyInstance, SurveyQuestion, SurveyResponse, SurveyTemplate, SurveyTracking
class SurveyQuestionSerializer(serializers.ModelSerializer):
@ -73,7 +73,7 @@ class SurveyInstanceSerializer(serializers.ModelSerializer):
fields = [
'id', 'survey_template', 'survey_template_name',
'patient', 'patient_name', 'patient_mrn',
'journey_instance', 'journey_stage_instance', 'encounter_id',
'journey_instance', 'encounter_id',
'delivery_channel', 'recipient_phone', 'recipient_email',
'access_token', 'token_expires_at', 'survey_url',
'status', 'sent_at', 'opened_at', 'completed_at',
@ -153,6 +153,79 @@ class SurveySubmissionSerializer(serializers.Serializer):
return survey_instance
class SurveyTrackingSerializer(serializers.ModelSerializer):
"""
Survey tracking events serializer.
Tracks detailed engagement metrics for surveys.
"""
survey_template_name = serializers.CharField(source='survey_instance.survey_template.name', read_only=True)
patient_name = serializers.CharField(source='survey_instance.patient.get_full_name', read_only=True)
class Meta:
model = SurveyTracking
fields = [
'id', 'survey_instance', 'survey_template_name', 'patient_name',
'event_type', 'time_on_page', 'total_time_spent',
'current_question', 'user_agent', 'ip_address',
'device_type', 'browser', 'country', 'city', 'metadata',
'created_at'
]
read_only_fields = ['id', 'created_at']
class SurveyInstanceAnalyticsSerializer(serializers.ModelSerializer):
"""
Enhanced survey instance serializer with tracking analytics.
"""
survey_template_name = serializers.CharField(source='survey_template.name', read_only=True)
patient_name = serializers.CharField(source='patient.get_full_name', read_only=True)
patient_mrn = serializers.CharField(source='patient.mrn', read_only=True)
responses = SurveyResponseSerializer(many=True, read_only=True)
survey_url = serializers.SerializerMethodField()
tracking_events_count = serializers.SerializerMethodField()
time_to_complete_minutes = serializers.SerializerMethodField()
class Meta:
model = SurveyInstance
fields = [
'id', 'survey_template', 'survey_template_name',
'patient', 'patient_name', 'patient_mrn',
'journey_instance', 'encounter_id',
'delivery_channel', 'recipient_phone', 'recipient_email',
'access_token', 'token_expires_at', 'survey_url',
'status', 'sent_at', 'opened_at', 'completed_at',
'open_count', 'last_opened_at', 'time_spent_seconds',
'total_score', 'is_negative',
'responses', 'metadata',
'tracking_events_count', 'time_to_complete_minutes',
'created_at', 'updated_at'
]
read_only_fields = [
'id', 'access_token', 'token_expires_at',
'sent_at', 'opened_at', 'completed_at',
'open_count', 'last_opened_at', 'time_spent_seconds',
'total_score', 'is_negative',
'tracking_events_count', 'time_to_complete_minutes',
'created_at', 'updated_at'
]
def get_survey_url(self, obj):
"""Get survey URL"""
return obj.get_survey_url()
def get_tracking_events_count(self, obj):
"""Get count of tracking events"""
return obj.tracking_events.count()
def get_time_to_complete_minutes(self, obj):
"""Calculate time to complete in minutes"""
if obj.sent_at and obj.completed_at:
time_diff = obj.completed_at - obj.sent_at
return round(time_diff.total_seconds() / 60, 2)
return None
class PublicSurveySerializer(serializers.ModelSerializer):
"""
Public survey serializer for patient-facing survey form.

View File

@ -66,7 +66,6 @@ def create_and_send_survey(self, stage_instance_id):
survey_template=stage_instance.stage_template.survey_template,
patient=patient,
journey_instance=stage_instance.journey_instance,
journey_stage_instance=stage_instance,
encounter_id=stage_instance.journey_instance.encounter_id,
delivery_channel=delivery_channel,
recipient_phone=recipient_phone,
@ -126,6 +125,89 @@ def create_and_send_survey(self, stage_instance_id):
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
@shared_task
def mark_abandoned_surveys(hours=24):
"""
Mark surveys as abandoned if not completed within specified time.
This task runs periodically to check for surveys that have been opened
or started but not completed. It marks them as 'abandoned' status.
Args:
hours: Hours after which to mark survey as abandoned (default: 24)
Returns:
dict: Result with count of surveys marked as abandoned
"""
from django.conf import settings
from apps.surveys.models import SurveyInstance, SurveyTracking
from datetime import timedelta
try:
# Get hours from settings if not provided
if hours is None:
hours = getattr(settings, 'SURVEY_ABANDONMENT_HOURS', 24)
logger.info(f"Checking for abandoned surveys (cutoff: {hours} hours)")
# Calculate cutoff time
cutoff_time = timezone.now() - timedelta(hours=hours)
# Find surveys that should be marked as abandoned
surveys_to_abandon = SurveyInstance.objects.filter(
status__in=['viewed', 'in_progress'],
token_expires_at__gt=timezone.now(),
last_opened_at__lt=cutoff_time
).select_related('survey_template', 'patient')
count = surveys_to_abandon.count()
if count == 0:
logger.info('No surveys to mark as abandoned')
return {'status': 'completed', 'marked': 0}
logger.info(f"Marking {count} surveys as abandoned")
# Mark surveys as abandoned
for survey in surveys_to_abandon:
time_since_open = timezone.now() - survey.last_opened_at
# Update status
survey.status = 'abandoned'
survey.save(update_fields=['status'])
# Get question count for this survey
tracking_events = survey.tracking_events.filter(
event_type='question_answered'
)
# Track abandonment event
SurveyTracking.objects.create(
survey_instance=survey,
event_type='survey_abandoned',
current_question=tracking_events.count(),
total_time_spent=survey.time_spent_seconds or 0,
metadata={
'time_since_open_hours': round(time_since_open.total_seconds() / 3600, 2),
'questions_answered': tracking_events.count(),
'original_status': survey.status,
}
)
logger.info(f"Successfully marked {count} surveys as abandoned")
return {
'status': 'completed',
'marked': count,
'hours_cutoff': hours
}
except Exception as e:
error_msg = f"Error marking abandoned surveys: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task
def send_survey_reminder(survey_instance_id):
"""
@ -250,6 +332,209 @@ def process_survey_completion(survey_instance_id):
return {'status': 'error', 'reason': error_msg}
@shared_task(bind=True, max_retries=3)
def create_post_discharge_survey(self, journey_instance_id):
"""
Create comprehensive post-discharge survey by merging questions from completed stages.
This task is triggered after patient discharge:
1. Gets all completed stages in the journey
2. Merges questions from each stage's survey_template
3. Creates a single comprehensive survey instance
4. Sends survey invitation to patient
Args:
journey_instance_id: UUID of PatientJourneyInstance
Returns:
dict: Result with survey_instance_id and delivery status
"""
from apps.core.services import create_audit_log
from apps.journeys.models import PatientJourneyInstance, StageStatus
from apps.notifications.services import NotificationService
from apps.surveys.models import SurveyInstance, SurveyQuestion, SurveyTemplate
try:
# Get journey instance
journey_instance = PatientJourneyInstance.objects.select_related(
'journey_template',
'patient',
'hospital'
).prefetch_related(
'stage_instances__stage_template__survey_template__questions'
).get(id=journey_instance_id)
logger.info(f"Creating post-discharge survey for journey {journey_instance_id}")
# Get all completed stages
completed_stages = journey_instance.stage_instances.filter(
status=StageStatus.COMPLETED
).select_related('stage_template__survey_template').order_by('stage_template__order')
if not completed_stages.exists():
logger.warning(f"No completed stages for journey {journey_instance_id}")
return {'status': 'skipped', 'reason': 'no_completed_stages'}
# Collect survey templates from completed stages
survey_templates = []
for stage_instance in completed_stages:
if stage_instance.stage_template.survey_template:
survey_templates.append({
'stage': stage_instance.stage_template,
'survey_template': stage_instance.stage_template.survey_template
})
logger.info(
f"Including questions from stage: {stage_instance.stage_template.name} "
f"(template: {stage_instance.stage_template.survey_template.name})"
)
if not survey_templates:
logger.warning(f"No survey templates found for completed stages in journey {journey_instance_id}")
return {'status': 'skipped', 'reason': 'no_survey_templates'}
# Create comprehensive survey template on-the-fly
from django.utils import timezone
import uuid
# Generate a unique name for this comprehensive survey
survey_name = f"Post-Discharge Survey - {journey_instance.patient.get_full_name()} - {journey_instance.encounter_id}"
survey_code = f"POST_DISCHARGE_{uuid.uuid4().hex[:8].upper()}"
# Create the survey template
comprehensive_template = SurveyTemplate.objects.create(
name=survey_name,
name_ar=f"استبيان ما بعد الخروج - {journey_instance.patient.get_full_name()}",
code=survey_code,
survey_type='general', # Use 'general' instead of SurveyType.GENERAL_FEEDBACK
hospital=journey_instance.hospital,
description=f"Comprehensive post-discharge survey for encounter {journey_instance.encounter_id}",
scoring_method='average',
negative_threshold=3.0,
is_active=True,
metadata={
'is_post_discharge_comprehensive': True,
'journey_instance_id': str(journey_instance.id),
'encounter_id': journey_instance.encounter_id,
'stages_count': len(survey_templates)
}
)
# Merge questions from all stage survey templates
question_order = 0
for stage_info in survey_templates:
stage_template = stage_info['stage']
stage_survey_template = stage_info['survey_template']
# Add section header for this stage
SurveyQuestion.objects.create(
survey_template=comprehensive_template,
text=f"--- {stage_template.name} ---",
text_ar=f"--- {stage_template.name_ar or stage_template.name} ---",
question_type='section_header',
order=question_order,
is_required=False,
weight=0,
metadata={'is_section_header': True, 'stage_name': stage_template.name}
)
question_order += 1
# Add all questions from this stage's template
for original_question in stage_survey_template.questions.filter(is_active=True).order_by('order'):
# Create a copy of the question
SurveyQuestion.objects.create(
survey_template=comprehensive_template,
text=original_question.text, # Use 'text' instead of 'question'
text_ar=original_question.text_ar, # Use 'text_ar' instead of 'question_ar'
question_type=original_question.question_type,
order=question_order,
is_required=original_question.is_required,
weight=original_question.weight,
choices_json=original_question.choices_json, # Use 'choices_json' instead of 'choices'
branch_logic=original_question.branch_logic,
metadata={
'original_question_id': str(original_question.id),
'original_stage': stage_template.name,
'original_survey_template': stage_survey_template.name
}
)
question_order += 1
logger.info(f"Added {stage_survey_template.questions.filter(is_active=True).count()} questions from {stage_template.name}")
logger.info(f"Created comprehensive survey template {comprehensive_template.id} with {question_order} items")
# Determine delivery channel and recipient
delivery_channel = 'sms' # Default
recipient_phone = journey_instance.patient.phone
recipient_email = journey_instance.patient.email
# Create survey instance
with transaction.atomic():
survey_instance = SurveyInstance.objects.create(
survey_template=comprehensive_template,
patient=journey_instance.patient,
journey_instance=journey_instance,
encounter_id=journey_instance.encounter_id,
delivery_channel=delivery_channel,
recipient_phone=recipient_phone,
recipient_email=recipient_email,
status='pending'
)
# Send survey invitation
notification_log = NotificationService.send_survey_invitation(
survey_instance=survey_instance,
language=journey_instance.patient.language if hasattr(journey_instance.patient, 'language') else 'en'
)
# Update survey instance status
survey_instance.status = 'active'
survey_instance.sent_at = timezone.now()
survey_instance.save(update_fields=['status', 'sent_at'])
# Log audit event
create_audit_log(
event_type='post_discharge_survey_sent',
description=f"Post-discharge survey sent to {journey_instance.patient.get_full_name()} for encounter {journey_instance.encounter_id}",
content_object=survey_instance,
metadata={
'survey_template': comprehensive_template.name,
'journey_instance': str(journey_instance.id),
'encounter_id': journey_instance.encounter_id,
'stages_included': len(survey_templates),
'total_questions': question_order,
'channel': delivery_channel
}
)
logger.info(
f"Post-discharge survey created and sent: {survey_instance.id} to "
f"{journey_instance.patient.get_full_name()} via {delivery_channel} "
f"({len(survey_templates)} stages, {question_order} questions)"
)
return {
'status': 'sent',
'survey_instance_id': str(survey_instance.id),
'survey_template_id': str(comprehensive_template.id),
'notification_log_id': str(notification_log.id),
'stages_included': len(survey_templates),
'total_questions': question_order
}
except PatientJourneyInstance.DoesNotExist:
error_msg = f"Journey instance {journey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error creating post-discharge survey: {str(e)}"
logger.error(error_msg, exc_info=True)
# Retry the task
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
@shared_task(bind=True, max_retries=3)
def send_satisfaction_feedback(self, survey_instance_id, user_id=None):
"""
@ -294,7 +579,7 @@ def send_satisfaction_feedback(self, survey_instance_id, user_id=None):
feedback = Feedback.objects.create(
patient=patient,
hospital=hospital,
department=survey_instance.journey_stage_instance.stage_template.department if survey_instance.journey_stage_instance else None,
department=None, # Department not directly linked to survey instance
feedback_type=FeedbackType.SATISFACTION_CHECK,
title=f"Satisfaction Check - {survey_instance.survey_template.name}",
message=f"Please rate your satisfaction with how we addressed your concerns regarding the survey.",

View File

@ -0,0 +1,6 @@
"""
Templatetags package for surveys app
"""
from .survey_filters import register # noqa
__all__ = ['register']

View File

@ -0,0 +1,48 @@
"""
Custom template filters for surveys app
"""
from django import template
register = template.Library()
@register.filter
def split(value, separator=','):
"""
Split a string by the given separator.
Usage: {{ "a,b,c"|split:"," }}
Returns: ['a', 'b', 'c']
"""
if not value:
return []
return value.split(separator)
@register.filter
def get_item(dictionary, key):
"""
Get an item from a dictionary by key.
Usage: {{ my_dict|get_item:"key_name" }}
Usage: {{ my_dict|get_item:variable_key }}
"""
if dictionary is None:
return None
try:
return dictionary.get(key)
except (AttributeError, KeyError, TypeError):
return None
@register.filter
def mul(value, arg):
"""
Multiply a value by a given number.
Usage: {{ value|mul:2 }}
"""
try:
return float(value) * float(arg)
except (ValueError, TypeError):
return 0

View File

@ -4,15 +4,18 @@ Survey Console UI views - Server-rendered templates for survey management
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, Prefetch
from django.db.models import Q, Prefetch, Avg, Count, F, Case, When, IntegerField
from django.db.models.functions import TruncDate
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.db.models import ExpressionWrapper, FloatField
from apps.core.services import AuditService
from apps.organizations.models import Department, Hospital
from .models import SurveyInstance, SurveyTemplate
from .forms import SurveyQuestionFormSet, SurveyTemplateForm
from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
from .tasks import send_satisfaction_feedback
@ -31,8 +34,7 @@ def survey_instance_list(request):
queryset = SurveyInstance.objects.select_related(
'survey_template',
'patient',
'journey_instance__journey_template',
'journey_stage_instance__stage_template'
'journey_instance__journey_template'
).prefetch_related(
'responses__question'
)
@ -99,22 +101,291 @@ def survey_instance_list(request):
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Get base queryset for statistics (without pagination)
stats_queryset = SurveyInstance.objects.select_related('survey_template')
# Apply same RBAC filters
if user.is_px_admin():
pass
elif user.is_hospital_admin() and user.hospital:
stats_queryset = stats_queryset.filter(survey_template__hospital=user.hospital)
elif user.hospital:
stats_queryset = stats_queryset.filter(survey_template__hospital=user.hospital)
else:
stats_queryset = stats_queryset.none()
# Apply same filters to stats
if status_filter:
stats_queryset = stats_queryset.filter(status=status_filter)
if survey_type:
stats_queryset = stats_queryset.filter(survey_template__survey_type=survey_type)
if is_negative == 'true':
stats_queryset = stats_queryset.filter(is_negative=True)
if hospital_filter:
stats_queryset = stats_queryset.filter(survey_template__hospital_id=hospital_filter)
if search_query:
stats_queryset = stats_queryset.filter(
Q(patient__mrn__icontains=search_query) |
Q(patient__first_name__icontains=search_query) |
Q(patient__last_name__icontains=search_query) |
Q(encounter_id__icontains=search_query)
)
if date_from:
stats_queryset = stats_queryset.filter(sent_at__gte=date_from)
if date_to:
stats_queryset = stats_queryset.filter(sent_at__lte=date_to)
# Statistics
total_count = stats_queryset.count()
# Include both 'sent' and 'pending' statuses for sent count
sent_count = stats_queryset.filter(status__in=['sent', 'pending']).count()
completed_count = stats_queryset.filter(status='completed').count()
negative_count = stats_queryset.filter(is_negative=True).count()
# Tracking statistics
opened_count = stats_queryset.filter(open_count__gt=0).count()
in_progress_count = stats_queryset.filter(status='in_progress').count()
abandoned_count = stats_queryset.filter(status='abandoned').count()
viewed_count = stats_queryset.filter(status='viewed').count()
pending_count = stats_queryset.filter(status='pending').count()
# Time metrics
completed_surveys = stats_queryset.filter(
status='completed',
time_spent_seconds__isnull=False
)
avg_completion_time = completed_surveys.aggregate(
avg_time=Avg('time_spent_seconds')
)['avg_time'] or 0
# Time to first open
surveys_with_open = stats_queryset.filter(
opened_at__isnull=False,
sent_at__isnull=False
)
if surveys_with_open.exists():
# Calculate average time to open
total_time_to_open = 0
count = 0
for survey in surveys_with_open:
if survey.opened_at and survey.sent_at:
total_time_to_open += (survey.opened_at - survey.sent_at).total_seconds()
count += 1
avg_time_to_open = total_time_to_open / count if count > 0 else 0
else:
avg_time_to_open = 0
stats = {
'total': queryset.count(),
'sent': queryset.filter(status='sent').count(),
'completed': queryset.filter(status='completed').count(),
'negative': queryset.filter(is_negative=True).count(),
'total': total_count,
'sent': sent_count,
'completed': completed_count,
'negative': negative_count,
'response_rate': round((completed_count / total_count * 100) if total_count > 0 else 0, 1),
# New tracking stats
'opened': opened_count,
'open_rate': round((opened_count / sent_count * 100) if sent_count > 0 else 0, 1),
'in_progress': in_progress_count,
'abandoned': abandoned_count,
'viewed': viewed_count,
'pending': pending_count,
'avg_completion_time': int(avg_completion_time),
'avg_time_to_open': int(avg_time_to_open),
}
# Score Distribution
score_distribution = []
score_ranges = [
('1-2', 1, 2),
('2-3', 2, 3),
('3-4', 3, 4),
('4-5', 4, 5),
]
for label, min_score, max_score in score_ranges:
# Use lte for the highest range to include exact match
if max_score == 5:
count = stats_queryset.filter(
total_score__gte=min_score,
total_score__lte=max_score
).count()
else:
count = stats_queryset.filter(
total_score__gte=min_score,
total_score__lt=max_score
).count()
score_distribution.append({
'range': label,
'count': count,
'percentage': round((count / total_count * 100) if total_count > 0 else 0, 1)
})
# Engagement Funnel Data - Include viewed and pending stages
engagement_funnel = [
{'stage': 'Sent/Pending', 'count': sent_count, 'percentage': 100},
{'stage': 'Viewed', 'count': viewed_count, 'percentage': round((viewed_count / sent_count * 100) if sent_count > 0 else 0, 1)},
{'stage': 'Opened', 'count': opened_count, 'percentage': round((opened_count / sent_count * 100) if sent_count > 0 else 0, 1)},
{'stage': 'In Progress', 'count': in_progress_count, 'percentage': round((in_progress_count / opened_count * 100) if opened_count > 0 else 0, 1)},
{'stage': 'Completed', 'count': completed_count, 'percentage': round((completed_count / sent_count * 100) if sent_count > 0 else 0, 1)},
]
# Completion Time Distribution
completion_time_ranges = [
('< 1 min', 0, 60),
('1-5 min', 60, 300),
('5-10 min', 300, 600),
('10-20 min', 600, 1200),
('20+ min', 1200, float('inf')),
]
completion_time_distribution = []
for label, min_seconds, max_seconds in completion_time_ranges:
if max_seconds == float('inf'):
count = completed_surveys.filter(time_spent_seconds__gte=min_seconds).count()
else:
count = completed_surveys.filter(
time_spent_seconds__gte=min_seconds,
time_spent_seconds__lt=max_seconds
).count()
completion_time_distribution.append({
'range': label,
'count': count,
'percentage': round((count / completed_count * 100) if completed_count > 0 else 0, 1)
})
# Device Type Distribution
device_distribution = []
from .models import SurveyTracking
tracking_events = SurveyTracking.objects.filter(
survey_instance__in=stats_queryset
).values('device_type').annotate(
count=Count('id')
).order_by('-count')
device_mapping = {
'mobile': 'Mobile',
'tablet': 'Tablet',
'desktop': 'Desktop',
}
for entry in tracking_events:
device_key = entry['device_type']
device_name = device_mapping.get(device_key, device_key.title())
count = entry['count']
percentage = round((count / tracking_events.count() * 100) if tracking_events.count() > 0 else 0, 1)
device_distribution.append({
'type': device_key,
'name': device_name,
'count': count,
'percentage': percentage
})
# Survey Trend (last 30 days) - Use created_at if sent_at is missing
from django.utils import timezone
import datetime
thirty_days_ago = timezone.now() - datetime.timedelta(days=30)
# Try sent_at first, fall back to created_at if sent_at is null
trend_queryset = stats_queryset.filter(
sent_at__gte=thirty_days_ago
)
# If no surveys with sent_at in last 30 days, try created_at
if not trend_queryset.exists():
trend_queryset = stats_queryset.filter(
created_at__gte=thirty_days_ago
).annotate(
date=TruncDate('created_at')
)
else:
trend_queryset = trend_queryset.annotate(
date=TruncDate('sent_at')
)
trend_data = trend_queryset.values('date').annotate(
sent=Count('id'),
completed=Count('id', filter=Q(status='completed'))
).order_by('date')
trend_labels = []
trend_sent = []
trend_completed = []
for entry in trend_data:
if entry['date']:
trend_labels.append(entry['date'].strftime('%Y-%m-%d'))
trend_sent.append(entry['sent'])
trend_completed.append(entry['completed'])
# Survey Type Distribution
survey_type_data = stats_queryset.values(
'survey_template__survey_type'
).annotate(
count=Count('id')
).order_by('-count')
survey_types = []
survey_type_labels = []
survey_type_counts = []
survey_type_mapping = {
'stage': 'Journey Stage',
'complaint_resolution': 'Complaint Resolution',
'general': 'General',
'nps': 'NPS',
}
for entry in survey_type_data:
type_key = entry['survey_template__survey_type']
type_name = survey_type_mapping.get(type_key, type_key.title())
count = entry['count']
percentage = round((count / total_count * 100) if total_count > 0 else 0, 1)
survey_types.append({
'type': type_key,
'name': type_name,
'count': count,
'percentage': percentage
})
survey_type_labels.append(type_name)
survey_type_counts.append(count)
# Serialize chart data to JSON for clean JavaScript usage
import json
context = {
'page_obj': page_obj,
'surveys': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'filters': request.GET,
# Visualization data as JSON for clean JavaScript
'engagement_funnel_json': json.dumps(engagement_funnel),
'completion_time_distribution_json': json.dumps(completion_time_distribution),
'device_distribution_json': json.dumps(device_distribution),
'score_distribution_json': json.dumps(score_distribution),
'survey_types_json': json.dumps(survey_types),
'trend_labels_json': json.dumps(trend_labels),
'trend_sent_json': json.dumps(trend_sent),
'trend_completed_json': json.dumps(trend_completed),
}
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.info(f"=== CHART DATA DEBUG ===")
logger.info(f"Score Distribution: {score_distribution}")
logger.info(f"Engagement Funnel: {engagement_funnel}")
logger.info(f"Completion Time Distribution: {completion_time_distribution}")
logger.info(f"Device Distribution: {device_distribution}")
logger.info(f"Total surveys in stats_queryset: {total_count}")
return render(request, 'surveys/instance_list.html', context)
@ -128,13 +399,14 @@ def survey_instance_detail(request, pk):
- All responses
- Score breakdown
- Related journey/stage info
- Score comparison with template average
- Related surveys from same patient
"""
survey = get_object_or_404(
SurveyInstance.objects.select_related(
'survey_template',
'patient',
'journey_instance__journey_template',
'journey_stage_instance__stage_template'
'journey_instance__journey_template'
).prefetch_related(
'responses__question'
),
@ -144,9 +416,71 @@ def survey_instance_detail(request, pk):
# Get responses
responses = survey.responses.all().order_by('question__order')
# Calculate average score for this survey template
template_average = SurveyInstance.objects.filter(
survey_template=survey.survey_template,
status='completed'
).aggregate(
avg_score=Avg('total_score')
)['avg_score'] or 0
# Get related surveys from the same patient
related_surveys = SurveyInstance.objects.filter(
patient=survey.patient,
status='completed'
).exclude(
id=survey.id
).select_related(
'survey_template'
).order_by('-completed_at')[:5]
# Get response statistics for each question (for choice questions)
question_stats = {}
for response in responses:
if response.question.question_type in ['multiple_choice', 'single_choice']:
choice_responses = SurveyInstance.objects.filter(
survey_template=survey.survey_template,
status='completed'
).values(
f'responses__choice_value'
).annotate(
count=Count('id')
).filter(
responses__question=response.question
).order_by('-count')
question_stats[response.question.id] = {
'type': 'choice',
'options': [
{
'value': opt['responses__choice_value'],
'count': opt['count'],
'percentage': round((opt['count'] / choice_responses.count() * 100) if choice_responses.count() > 0 else 0, 1)
}
for opt in choice_responses
if opt['responses__choice_value']
]
}
elif response.question.question_type == 'rating':
rating_stats = SurveyInstance.objects.filter(
survey_template=survey.survey_template,
status='completed'
).aggregate(
avg_rating=Avg('responses__numeric_value'),
total_responses=Count('responses')
)
question_stats[response.question.id] = {
'type': 'rating',
'average': round(rating_stats['avg_rating'] or 0, 2),
'total_responses': rating_stats['total_responses'] or 0
}
context = {
'survey': survey,
'responses': responses,
'template_average': round(template_average, 2),
'related_surveys': related_surveys,
'question_stats': question_stats,
}
return render(request, 'surveys/instance_detail.html', context)
@ -205,6 +539,141 @@ def survey_template_list(request):
return render(request, 'surveys/template_list.html', context)
@login_required
def survey_template_create(request):
"""Create a new survey template with questions"""
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to create survey templates.")
return redirect('surveys:template_list')
if request.method == 'POST':
form = SurveyTemplateForm(request.POST)
formset = SurveyQuestionFormSet(request.POST)
if form.is_valid() and formset.is_valid():
template = form.save(commit=False)
template.created_by = user
template.save()
questions = formset.save(commit=False)
for question in questions:
question.survey_template = template
question.save()
messages.success(request, "Survey template created successfully.")
return redirect('surveys:template_detail', pk=template.pk)
else:
form = SurveyTemplateForm()
formset = SurveyQuestionFormSet()
context = {
'form': form,
'formset': formset,
}
return render(request, 'surveys/template_form.html', context)
@login_required
def survey_template_detail(request, pk):
"""View survey template details"""
template = get_object_or_404(
SurveyTemplate.objects.select_related('hospital').prefetch_related('questions'),
pk=pk
)
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
if user.hospital and template.hospital != user.hospital:
messages.error(request, "You don't have permission to view this template.")
return redirect('surveys:template_list')
# Get statistics
total_instances = template.instances.count()
completed_instances = template.instances.filter(status='completed').count()
negative_instances = template.instances.filter(is_negative=True).count()
avg_score = template.instances.filter(status='completed').aggregate(
avg_score=Avg('total_score')
)['avg_score'] or 0
context = {
'template': template,
'questions': template.questions.all().order_by('order'),
'stats': {
'total_instances': total_instances,
'completed_instances': completed_instances,
'negative_instances': negative_instances,
'completion_rate': round((completed_instances / total_instances * 100) if total_instances > 0 else 0, 1),
'avg_score': round(avg_score, 2),
}
}
return render(request, 'surveys/template_detail.html', context)
@login_required
def survey_template_edit(request, pk):
"""Edit an existing survey template with questions"""
template = get_object_or_404(SurveyTemplate, pk=pk)
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
if user.hospital and template.hospital != user.hospital:
messages.error(request, "You don't have permission to edit this template.")
return redirect('surveys:template_list')
if request.method == 'POST':
form = SurveyTemplateForm(request.POST, instance=template)
formset = SurveyQuestionFormSet(request.POST, instance=template)
if form.is_valid() and formset.is_valid():
form.save()
formset.save()
messages.success(request, "Survey template updated successfully.")
return redirect('surveys:template_detail', pk=template.pk)
else:
form = SurveyTemplateForm(instance=template)
formset = SurveyQuestionFormSet(instance=template)
context = {
'form': form,
'formset': formset,
'template': template,
}
return render(request, 'surveys/template_form.html', context)
@login_required
def survey_template_delete(request, pk):
"""Delete a survey template"""
template = get_object_or_404(SurveyTemplate, pk=pk)
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
if user.hospital and template.hospital != user.hospital:
messages.error(request, "You don't have permission to delete this template.")
return redirect('surveys:template_list')
if request.method == 'POST':
template_name = template.name
template.delete()
messages.success(request, f"Survey template '{template_name}' deleted successfully.")
return redirect('surveys:template_list')
context = {
'template': template,
}
return render(request, 'surveys/template_confirm_delete.html', context)
@login_required
@require_http_methods(["POST"])
def survey_log_patient_contact(request, pk):

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