Compare commits
9 Commits
7d6d75b10b
...
3c44f28d33
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c44f28d33 | |||
| 42cf7bf8f1 | |||
| 3ce62d80e1 | |||
| 9d586a4ed3 | |||
| 65490078bb | |||
| d0a2d5db7b | |||
| aac8698df4 | |||
| dcb6455819 | |||
| 4dd3c3e505 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -70,3 +70,7 @@ Thumbs.db
|
||||
|
||||
# Docker volumes
|
||||
postgres_data/
|
||||
|
||||
# Django migrations (exclude __init__.py)
|
||||
**/migrations/*.py
|
||||
!**/migrations/__init__.py
|
||||
|
||||
217
POST_DISCHARGE_SURVEY_IMPLEMENTATION.md
Normal file
217
POST_DISCHARGE_SURVEY_IMPLEMENTATION.md
Normal 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
177
SURVEY_CHARTS_EMPTY_FIX.md
Normal 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`.
|
||||
76
SURVEY_CHARTS_FIX_SUMMARY.md
Normal file
76
SURVEY_CHARTS_FIX_SUMMARY.md
Normal 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
|
||||
@ -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'), {
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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 = [
|
||||
]
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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})'
|
||||
)
|
||||
)
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -1 +0,0 @@
|
||||
# Migrations module
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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)"""
|
||||
|
||||
|
||||
128
apps/complaints/management/commands/sync_complaint_types.py
Normal file
128
apps/complaints/management/commands/sync_complaint_types.py
Normal 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}")
|
||||
@ -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()
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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 = [
|
||||
]
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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()
|
||||
|
||||
@ -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
92
apps/journeys/forms.py
Normal 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
|
||||
)
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
]
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -1 +0,0 @@
|
||||
# Observations migrations
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)}')
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1 +0,0 @@
|
||||
# PX Sources migrations
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -1 +0,0 @@
|
||||
# Migrations for references app
|
||||
352
apps/simulator/his_simulator.py
Normal file
352
apps/simulator/his_simulator.py
Normal 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()
|
||||
290
apps/simulator/management/commands/seed_journey_surveys.py
Normal file
290
apps/simulator/management/commands/seed_journey_surveys.py
Normal 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'))
|
||||
25
apps/simulator/serializers.py
Normal file
25
apps/simulator/serializers.py
Normal 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)
|
||||
@ -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'),
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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
375
apps/surveys/analytics.py
Normal 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
|
||||
268
apps/surveys/analytics_views.py
Normal file
268
apps/surveys/analytics_views.py
Normal 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
91
apps/surveys/forms.py
Normal 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
|
||||
)
|
||||
134
apps/surveys/management/commands/create_demo_survey.py
Normal file
134
apps/surveys/management/commands/create_demo_survey.py
Normal 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))
|
||||
121
apps/surveys/management/commands/mark_abandoned_surveys.py
Normal file
121
apps/surveys/management/commands/mark_abandoned_surveys.py
Normal 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"
|
||||
))
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.",
|
||||
|
||||
6
apps/surveys/templatetags/__init__.py
Normal file
6
apps/surveys/templatetags/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""
|
||||
Templatetags package for surveys app
|
||||
"""
|
||||
from .survey_filters import register # noqa
|
||||
|
||||
__all__ = ['register']
|
||||
48
apps/surveys/templatetags/survey_filters.py
Normal file
48
apps/surveys/templatetags/survey_filters.py
Normal 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
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user