update complain and add ai and sentiment analysis
This commit is contained in:
parent
7d56370811
commit
eb578d9f9b
@ -20,6 +20,12 @@ EMAIL_HOST_USER=
|
|||||||
EMAIL_HOST_PASSWORD=
|
EMAIL_HOST_PASSWORD=
|
||||||
DEFAULT_FROM_EMAIL=noreply@px360.sa
|
DEFAULT_FROM_EMAIL=noreply@px360.sa
|
||||||
|
|
||||||
|
# AI Configuration (LiteLLM with OpenRouter)
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
AI_MODEL=openai/gpt-4o-mini
|
||||||
|
AI_TEMPERATURE=0.3
|
||||||
|
AI_MAX_TOKENS=500
|
||||||
|
|
||||||
# Notification Channels
|
# Notification Channels
|
||||||
SMS_ENABLED=False
|
SMS_ENABLED=False
|
||||||
SMS_PROVIDER=console
|
SMS_PROVIDER=console
|
||||||
|
|||||||
120
COMPLAINT_CATEGORIES_FIX.md
Normal file
120
COMPLAINT_CATEGORIES_FIX.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# Complaint Categories Fix - Multi-Hospital Support
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
The ComplaintCategory model had a ForeignKey relationship to Hospital, which prevented categories from being shared across multiple hospitals. Each category could only belong to one hospital.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Changed the ComplaintCategory model from a ForeignKey to a ManyToMany relationship with Hospital. This allows:
|
||||||
|
- Categories to be assigned to multiple hospitals
|
||||||
|
- Categories with no hospitals (system-wide) to be available to all hospitals
|
||||||
|
- Each hospital to have its own custom categories while also accessing system-wide categories
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Model Changes (apps/complaints/models.py)
|
||||||
|
- Changed `hospital = models.ForeignKey(...)` to `hospitals = models.ManyToManyField(...)`
|
||||||
|
- Removed `Meta.indexes` that referenced the old hospital field
|
||||||
|
- Removed hospital-related `Meta.constraints`
|
||||||
|
- Updated docstrings to reflect the new relationship
|
||||||
|
|
||||||
|
### 2. Migration Created (apps/complaints/migrations/0003_alter_complaintcategory_options_and_more.py)
|
||||||
|
- Removes the old `hospital` field and its index
|
||||||
|
- Adds the new `hospitals` ManyToMany field
|
||||||
|
- Applies the changes successfully
|
||||||
|
|
||||||
|
### 3. Admin Interface Updated (apps/complaints/admin.py)
|
||||||
|
- Added filter_horizontal = ['hospitals'] for better UI
|
||||||
|
- Updated `hospitals_display` method to handle ManyToMany
|
||||||
|
- Shows hospital count or "System-wide" for categories
|
||||||
|
|
||||||
|
### 4. Management Command Updated (apps/complaints/management/commands/load_complaint_categories.py)
|
||||||
|
- Removed hospital reference from category/subcategory creation
|
||||||
|
- Categories are now created without hospital assignments (system-wide)
|
||||||
|
- Works correctly with the ManyToMany field
|
||||||
|
|
||||||
|
### 5. API Endpoint Updated (apps/complaints/ui_views.py)
|
||||||
|
```python
|
||||||
|
# Old code:
|
||||||
|
categories_queryset = ComplaintCategory.objects.filter(
|
||||||
|
Q(hospital_id=hospital_id) | Q(hospital__isnull=True),
|
||||||
|
is_active=True
|
||||||
|
).order_by('-hospital', 'order', 'name_en')
|
||||||
|
|
||||||
|
# New code:
|
||||||
|
categories_queryset = ComplaintCategory.objects.filter(
|
||||||
|
Q(hospitals__id=hospital_id) | Q(hospitals__isnull=True),
|
||||||
|
is_active=True
|
||||||
|
).distinct().order_by('order', 'name_en')
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
- Changed `hospital_id=hospital_id` to `hospitals__id=hospital_id`
|
||||||
|
- Changed `hospital__isnull=True` to `hospitals__isnull=True`
|
||||||
|
- Added `.distinct()` to remove duplicates
|
||||||
|
- Removed `-hospital` from ordering (no longer applicable)
|
||||||
|
|
||||||
|
## Testing Results
|
||||||
|
|
||||||
|
### Database Verification
|
||||||
|
```
|
||||||
|
Total parent categories: 5
|
||||||
|
Categories with hospitals: 0
|
||||||
|
System-wide categories: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoint Test
|
||||||
|
```
|
||||||
|
Response status: 200
|
||||||
|
Categories count: 21
|
||||||
|
Sample categories:
|
||||||
|
- Cleanliness (Parent ID: 8952a7e3..., ID: 43ec2d94...) ← Subcategory
|
||||||
|
- Diagnosis concerns (Parent ID: 9e99195c..., ID: 20ce76ab...) ← Subcategory
|
||||||
|
- Privacy & Confidentiality (Parent ID: 755b053e..., ID: 564583fd...) ← Subcategory
|
||||||
|
- Quality of Care & Treatment (Parent ID: None, ID: 9e99195c...) ← Parent
|
||||||
|
- Staff attitude (Parent ID: b6302801..., ID: ffa88ba9...) ← Subcategory
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### System-Wide Categories (Default)
|
||||||
|
Categories created without any hospital assignments are available to ALL hospitals:
|
||||||
|
```python
|
||||||
|
category = ComplaintCategory.objects.create(
|
||||||
|
code='quality_care',
|
||||||
|
name_en='Quality of Care & Treatment',
|
||||||
|
# No hospitals specified → available to all
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hospital-Specific Categories
|
||||||
|
Categories can be assigned to specific hospitals:
|
||||||
|
```python
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
|
category = ComplaintCategory.objects.create(
|
||||||
|
code='special_category',
|
||||||
|
name_en='Special Category'
|
||||||
|
)
|
||||||
|
category.hospitals.add(*hospitals) # Assign to multiple hospitals
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying Categories
|
||||||
|
The API endpoint returns:
|
||||||
|
1. Hospital-specific categories (assigned to the hospital)
|
||||||
|
2. System-wide categories (no hospital assignment)
|
||||||
|
3. Both parent categories and their subcategories
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Flexibility**: Hospitals can share common categories while maintaining custom ones
|
||||||
|
2. **Efficiency**: No need to duplicate categories for each hospital
|
||||||
|
3. **Scalability**: Easy to add new categories that apply to all hospitals
|
||||||
|
4. **Maintainability**: System-wide changes can be made in one place
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Category Prioritization**: Add a field to prioritize hospital-specific over system-wide categories
|
||||||
|
2. **Category Copying**: Create a management command to copy system-wide categories to hospital-specific
|
||||||
|
3. **Category Versioning**: Track changes to categories over time
|
||||||
|
4. **Category Analytics**: Report on which categories are most used per hospital
|
||||||
249
PUBLIC_COMPLAINT_FORM.md
Normal file
249
PUBLIC_COMPLAINT_FORM.md
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
# Public Complaint Form Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A public-facing complaint submission form that allows patients and visitors to submit complaints without requiring authentication. The form is fully bilingual (English/Arabic) and provides a seamless user experience.
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### 1. Form Components
|
||||||
|
- **PublicComplaintForm** (apps/complaints/forms.py)
|
||||||
|
- Django form with validation for all complaint fields
|
||||||
|
- Supports file attachments (up to 5 files, 10MB each)
|
||||||
|
- Validates national ID format (10 digits)
|
||||||
|
- Dynamic field requirements based on patient lookup
|
||||||
|
|
||||||
|
### 2. Templates
|
||||||
|
- **public_complaint_form.html** - Main submission form
|
||||||
|
- Patient Information Section
|
||||||
|
- National ID lookup with auto-fill
|
||||||
|
- Auto-generated MRN display
|
||||||
|
- Contact information (name, phone, email)
|
||||||
|
- Preferred language selection
|
||||||
|
|
||||||
|
- Complaint Details Section
|
||||||
|
- Hospital and department dropdowns (cascading)
|
||||||
|
- Complaint category and subcategory
|
||||||
|
- Title and description fields
|
||||||
|
- Severity and priority selectors
|
||||||
|
- Encounter ID (optional)
|
||||||
|
|
||||||
|
- Attachments Section
|
||||||
|
- Multi-file upload with preview
|
||||||
|
- File size and type validation
|
||||||
|
|
||||||
|
- AJAX-powered interactions
|
||||||
|
- Patient lookup by National ID
|
||||||
|
- Department dropdown based on hospital selection
|
||||||
|
- Form submission with loading states
|
||||||
|
- Success modal with reference number
|
||||||
|
|
||||||
|
- **public_complaint_success.html** - Success confirmation page
|
||||||
|
- Displays complaint reference number
|
||||||
|
- Next steps information
|
||||||
|
- Contact information for urgent cases
|
||||||
|
- Navigation options
|
||||||
|
|
||||||
|
### 3. View Handlers (apps/complaints/ui_views.py)
|
||||||
|
|
||||||
|
#### public_complaint_submit
|
||||||
|
- Handles GET (display form) and POST (submit complaint)
|
||||||
|
- Creates new patient records when needed
|
||||||
|
- Generates unique reference numbers
|
||||||
|
- Handles file attachments
|
||||||
|
- Supports both traditional and AJAX submissions
|
||||||
|
- Validates all form inputs
|
||||||
|
- Creates initial complaint update
|
||||||
|
|
||||||
|
#### public_complaint_success
|
||||||
|
- Displays success page with reference number
|
||||||
|
- No authentication required
|
||||||
|
|
||||||
|
#### api_lookup_patient
|
||||||
|
- AJAX endpoint for patient lookup by National ID
|
||||||
|
- Returns patient MRN, name, phone, email
|
||||||
|
- No authentication required
|
||||||
|
- Handles patient not found cases
|
||||||
|
|
||||||
|
#### api_load_departments
|
||||||
|
- AJAX endpoint for cascading department dropdown
|
||||||
|
- Returns active departments for selected hospital
|
||||||
|
- No authentication required
|
||||||
|
|
||||||
|
### 4. URL Routes (apps/complaints/urls.py)
|
||||||
|
- `/complaints/public/submit/` - Public form
|
||||||
|
- `/complaints/public/success/<reference>/` - Success page
|
||||||
|
- `/complaints/public/api/lookup-patient/` - Patient lookup API
|
||||||
|
- `/complaints/public/api/load-departments/` - Department loading API
|
||||||
|
|
||||||
|
### 5. Key Features
|
||||||
|
|
||||||
|
#### National ID Lookup
|
||||||
|
- Real-time patient lookup with debounce (500ms)
|
||||||
|
- Auto-fills MRN, name, phone, email
|
||||||
|
- Shows loading spinner during lookup
|
||||||
|
- Handles not found case with fallback to manual entry
|
||||||
|
|
||||||
|
#### Cascading Dropdowns
|
||||||
|
- Hospital selection loads corresponding departments
|
||||||
|
- Departments disabled until hospital selected
|
||||||
|
- Shows error message on failure
|
||||||
|
|
||||||
|
#### File Upload
|
||||||
|
- Multiple file support
|
||||||
|
- Preview shows file names and sizes
|
||||||
|
- Client-side validation
|
||||||
|
- Server-side validation (type, size, count)
|
||||||
|
|
||||||
|
#### Form Validation
|
||||||
|
- Required field validation
|
||||||
|
- National ID format validation
|
||||||
|
- File type and size validation
|
||||||
|
- AJAX error handling with detailed messages
|
||||||
|
- SweetAlert2 for user-friendly error messages
|
||||||
|
|
||||||
|
#### Bilingual Support
|
||||||
|
- All text uses Django i18n templates
|
||||||
|
- Arabic and English translations
|
||||||
|
- RTL language support
|
||||||
|
- Language-aware date/time formatting
|
||||||
|
|
||||||
|
#### User Experience
|
||||||
|
- Responsive design for mobile and desktop
|
||||||
|
- Loading states for all async operations
|
||||||
|
- Success modal with reference number
|
||||||
|
- Clear visual feedback for all interactions
|
||||||
|
- Informative help text throughout
|
||||||
|
|
||||||
|
### 6. Security Considerations
|
||||||
|
- CSRF protection enabled
|
||||||
|
- File type validation (images, PDF, DOC, DOCX)
|
||||||
|
- File size limits (10MB per file, 5 files max)
|
||||||
|
- Input sanitization via Django forms
|
||||||
|
- Rate limiting should be added in production
|
||||||
|
|
||||||
|
### 7. Patient Creation Logic
|
||||||
|
When a patient is not found:
|
||||||
|
- Auto-generates MRN using Patient.generate_mrn()
|
||||||
|
- Splits full name into first/last name
|
||||||
|
- Sets selected hospital as primary
|
||||||
|
- Sets status to 'active'
|
||||||
|
- Stores all provided contact information
|
||||||
|
|
||||||
|
### 8. Complaint Creation Logic
|
||||||
|
- Sets source='public' to identify public submissions
|
||||||
|
- Starts with status='open'
|
||||||
|
- Creates initial update note
|
||||||
|
- Links to patient (new or existing)
|
||||||
|
- Stores all form data
|
||||||
|
|
||||||
|
### 9. Integration Points
|
||||||
|
- Uses existing Patient model from apps.organizations
|
||||||
|
- Uses Hospital and Department models
|
||||||
|
- Creates Complaint and ComplaintAttachment records
|
||||||
|
- Creates initial ComplaintUpdate for audit trail
|
||||||
|
- Reference number format: CMP-{YYYYMMDD}-{unique_id}
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Accessing the Form
|
||||||
|
The public form is accessible at:
|
||||||
|
```
|
||||||
|
/complaints/public/submit/
|
||||||
|
```
|
||||||
|
|
||||||
|
No authentication required.
|
||||||
|
|
||||||
|
### Example Submission Flow
|
||||||
|
1. User enters National ID (10 digits)
|
||||||
|
2. System looks up patient and auto-fills data
|
||||||
|
3. If not found, user manually enters contact info
|
||||||
|
4. User selects hospital (loads departments)
|
||||||
|
5. User optionally selects department
|
||||||
|
6. User selects complaint category
|
||||||
|
7. User enters title and description
|
||||||
|
8. User sets severity and priority
|
||||||
|
9. User optionally uploads attachments
|
||||||
|
10. User submits form
|
||||||
|
11. System creates patient (if needed) and complaint
|
||||||
|
12. User sees success page with reference number
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Form loads without authentication
|
||||||
|
- [ ] National ID lookup works with valid patient
|
||||||
|
- [ ] National ID lookup shows not found message for invalid ID
|
||||||
|
- [ ] Hospital dropdown loads active hospitals
|
||||||
|
- [ ] Department dropdown cascades correctly
|
||||||
|
- [ ] File upload preview shows file info
|
||||||
|
- [ ] Form validates required fields
|
||||||
|
- [ ] Form validates file types and sizes
|
||||||
|
- [ ] Complaint is created successfully
|
||||||
|
- [ ] Patient is created when not found
|
||||||
|
- [ ] Reference number is generated
|
||||||
|
- [ ] Success page displays correctly
|
||||||
|
- [ ] AJAX submissions work
|
||||||
|
- [ ] Traditional submissions work
|
||||||
|
- [ ] Bilingual switching works
|
||||||
|
- [ ] RTL layout displays correctly
|
||||||
|
- [ ] Mobile responsive design works
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Email Notifications**
|
||||||
|
- Send confirmation email to patient
|
||||||
|
- Send notification to hospital staff
|
||||||
|
|
||||||
|
2. **SMS Notifications**
|
||||||
|
- Send SMS with reference number
|
||||||
|
- Send status update notifications
|
||||||
|
|
||||||
|
3. **Complaint Tracking**
|
||||||
|
- Allow users to check status by reference number
|
||||||
|
- Public status page
|
||||||
|
|
||||||
|
4. **Rate Limiting**
|
||||||
|
- Prevent abuse from same IP
|
||||||
|
- CAPTCHA for suspicious activity
|
||||||
|
|
||||||
|
5. **Additional Validation**
|
||||||
|
- Phone number format validation
|
||||||
|
- Email format validation
|
||||||
|
- Custom validation rules per hospital
|
||||||
|
|
||||||
|
6. **Enhanced UX**
|
||||||
|
- Progress indicator
|
||||||
|
- Save draft functionality
|
||||||
|
- Multi-step form with review
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Django (form handling)
|
||||||
|
- jQuery (AJAX)
|
||||||
|
- SweetAlert2 (alerts)
|
||||||
|
- Bootstrap 4 (styling)
|
||||||
|
- Django i18n (translations)
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
### Created
|
||||||
|
- apps/complaints/forms.py - PublicComplaintForm
|
||||||
|
- templates/complaints/public_complaint_form.html - Main form
|
||||||
|
- templates/complaints/public_complaint_success.html - Success page
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
- apps/complaints/ui_views.py - Added public form views
|
||||||
|
- apps/complaints/urls.py - Added public form routes
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
1. Ensure media files are properly configured for file uploads
|
||||||
|
2. Set up email backend for notifications
|
||||||
|
3. Configure CORS if needed for AJAX requests
|
||||||
|
4. Review and adjust file size limits as needed
|
||||||
|
5. Ensure translation files are updated for new strings
|
||||||
|
6. Consider adding analytics tracking
|
||||||
|
7. Set up monitoring for form submissions
|
||||||
|
|
||||||
|
## Support
|
||||||
|
For issues or questions about the public complaint form, contact:
|
||||||
|
- Development Team
|
||||||
|
- Patient Relations Department
|
||||||
@ -116,3 +116,9 @@ USE_TZ = True
|
|||||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
OPENROUTER_API_KEY = "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"
|
||||||
|
AI_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:07
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:07
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -43,13 +43,13 @@ User = get_user_model()
|
|||||||
|
|
||||||
class CustomTokenObtainPairView(TokenObtainPairView):
|
class CustomTokenObtainPairView(TokenObtainPairView):
|
||||||
"""
|
"""
|
||||||
Custom JWT token view that logs user login.
|
Custom JWT token view that logs user login and provides redirect info.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
response = super().post(request, *args, **kwargs)
|
response = super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
# Log successful login
|
# Log successful login and add redirect info
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
username = request.data.get('username')
|
username = request.data.get('username')
|
||||||
try:
|
try:
|
||||||
@ -60,16 +60,41 @@ class CustomTokenObtainPairView(TokenObtainPairView):
|
|||||||
request=request,
|
request=request,
|
||||||
content_object=user
|
content_object=user
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add redirect URL to response data
|
||||||
|
response_data = response.data
|
||||||
|
response_data['redirect_url'] = self.get_redirect_url(user)
|
||||||
|
response.data = response_data
|
||||||
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def get_redirect_url(self, user):
|
||||||
|
"""
|
||||||
|
Determine the appropriate redirect URL based on user role and hospital context.
|
||||||
|
"""
|
||||||
|
# PX Admins need to select a hospital first
|
||||||
|
if user.is_px_admin():
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
# Check if there's already a hospital in session
|
||||||
|
# Since we don't have access to request here, frontend should handle this
|
||||||
|
# Return the hospital selector URL
|
||||||
|
return '/health/select-hospital/'
|
||||||
|
|
||||||
|
# Users without hospital assignment get error page
|
||||||
|
if not user.hospital:
|
||||||
|
return '/health/no-hospital/'
|
||||||
|
|
||||||
|
# All other users go to dashboard
|
||||||
|
return '/'
|
||||||
|
|
||||||
|
|
||||||
class UserViewSet(viewsets.ModelViewSet):
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for User model.
|
ViewSet for User model.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- List/Retrieve: Authenticated users
|
- List/Retrieve: Authenticated users
|
||||||
- Create/Update/Delete: PX Admins only
|
- Create/Update/Delete: PX Admins only
|
||||||
@ -81,7 +106,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
search_fields = ['username', 'email', 'first_name', 'last_name', 'employee_id']
|
search_fields = ['username', 'email', 'first_name', 'last_name', 'employee_id']
|
||||||
ordering_fields = ['date_joined', 'email', 'last_name']
|
ordering_fields = ['date_joined', 'email', 'last_name']
|
||||||
ordering = ['-date_joined']
|
ordering = ['-date_joined']
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
"""Return appropriate serializer based on action"""
|
"""Return appropriate serializer based on action"""
|
||||||
if self.action == 'create':
|
if self.action == 'create':
|
||||||
@ -89,7 +114,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
elif self.action in ['update', 'partial_update']:
|
elif self.action in ['update', 'partial_update']:
|
||||||
return UserUpdateSerializer
|
return UserUpdateSerializer
|
||||||
return UserSerializer
|
return UserSerializer
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""Set permissions based on action"""
|
"""Set permissions based on action"""
|
||||||
if self.action in ['create', 'destroy']:
|
if self.action in ['create', 'destroy']:
|
||||||
@ -97,27 +122,27 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
elif self.action in ['update', 'partial_update']:
|
elif self.action in ['update', 'partial_update']:
|
||||||
return [IsOwnerOrPXAdmin()]
|
return [IsOwnerOrPXAdmin()]
|
||||||
return [IsAuthenticated()]
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter queryset based on user role"""
|
"""Filter queryset based on user role"""
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all users
|
# PX Admins see all users
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset.select_related('hospital', 'department')
|
return queryset.select_related('hospital', 'department')
|
||||||
|
|
||||||
# Hospital Admins see users in their hospital
|
# Hospital Admins see users in their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital).select_related('hospital', 'department')
|
return queryset.filter(hospital=user.hospital).select_related('hospital', 'department')
|
||||||
|
|
||||||
# Department Managers see users in their department
|
# Department Managers see users in their department
|
||||||
if user.is_department_manager() and user.department:
|
if user.is_department_manager() and user.department:
|
||||||
return queryset.filter(department=user.department).select_related('hospital', 'department')
|
return queryset.filter(department=user.department).select_related('hospital', 'department')
|
||||||
|
|
||||||
# Others see only themselves
|
# Others see only themselves
|
||||||
return queryset.filter(id=user.id)
|
return queryset.filter(id=user.id)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Log user creation"""
|
"""Log user creation"""
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
@ -127,7 +152,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
request=self.request,
|
request=self.request,
|
||||||
content_object=user
|
content_object=user
|
||||||
)
|
)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""Log user update"""
|
"""Log user update"""
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
@ -137,58 +162,58 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
request=self.request,
|
request=self.request,
|
||||||
content_object=user
|
content_object=user
|
||||||
)
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||||
def me(self, request):
|
def me(self, request):
|
||||||
"""Get current user profile"""
|
"""Get current user profile"""
|
||||||
serializer = self.get_serializer(request.user)
|
serializer = self.get_serializer(request.user)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=False, methods=['put'], permission_classes=[IsAuthenticated])
|
@action(detail=False, methods=['put'], permission_classes=[IsAuthenticated])
|
||||||
def update_profile(self, request):
|
def update_profile(self, request):
|
||||||
"""Update current user profile"""
|
"""Update current user profile"""
|
||||||
serializer = UserUpdateSerializer(request.user, data=request.data, partial=True)
|
serializer = UserUpdateSerializer(request.user, data=request.data, partial=True)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
AuditService.log_from_request(
|
AuditService.log_from_request(
|
||||||
event_type='other',
|
event_type='other',
|
||||||
description=f"User {request.user.email} updated their profile",
|
description=f"User {request.user.email} updated their profile",
|
||||||
request=request,
|
request=request,
|
||||||
content_object=request.user
|
content_object=request.user
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(UserSerializer(request.user).data)
|
return Response(UserSerializer(request.user).data)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
|
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
|
||||||
def change_password(self, request):
|
def change_password(self, request):
|
||||||
"""Change user password"""
|
"""Change user password"""
|
||||||
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
|
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
# Change password
|
# Change password
|
||||||
request.user.set_password(serializer.validated_data['new_password'])
|
request.user.set_password(serializer.validated_data['new_password'])
|
||||||
request.user.save()
|
request.user.save()
|
||||||
|
|
||||||
AuditService.log_from_request(
|
AuditService.log_from_request(
|
||||||
event_type='other',
|
event_type='other',
|
||||||
description=f"User {request.user.email} changed their password",
|
description=f"User {request.user.email} changed their password",
|
||||||
request=request,
|
request=request,
|
||||||
content_object=request.user
|
content_object=request.user
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response({'message': 'Password changed successfully'}, status=status.HTTP_200_OK)
|
return Response({'message': 'Password changed successfully'}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
|
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
|
||||||
def assign_role(self, request, pk=None):
|
def assign_role(self, request, pk=None):
|
||||||
"""Assign role to user (PX Admin only)"""
|
"""Assign role to user (PX Admin only)"""
|
||||||
user = self.get_object()
|
user = self.get_object()
|
||||||
role_id = request.data.get('role_id')
|
role_id = request.data.get('role_id')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
role = Role.objects.get(id=role_id)
|
role = Role.objects.get(id=role_id)
|
||||||
user.groups.add(role.group)
|
user.groups.add(role.group)
|
||||||
|
|
||||||
AuditService.log_from_request(
|
AuditService.log_from_request(
|
||||||
event_type='role_change',
|
event_type='role_change',
|
||||||
description=f"Role {role.display_name} assigned to user {user.email}",
|
description=f"Role {role.display_name} assigned to user {user.email}",
|
||||||
@ -196,21 +221,21 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
content_object=user,
|
content_object=user,
|
||||||
metadata={'role': role.name}
|
metadata={'role': role.name}
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response({'message': f'Role {role.display_name} assigned successfully'})
|
return Response({'message': f'Role {role.display_name} assigned successfully'})
|
||||||
except Role.DoesNotExist:
|
except Role.DoesNotExist:
|
||||||
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
|
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
|
||||||
def remove_role(self, request, pk=None):
|
def remove_role(self, request, pk=None):
|
||||||
"""Remove role from user (PX Admin only)"""
|
"""Remove role from user (PX Admin only)"""
|
||||||
user = self.get_object()
|
user = self.get_object()
|
||||||
role_id = request.data.get('role_id')
|
role_id = request.data.get('role_id')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
role = Role.objects.get(id=role_id)
|
role = Role.objects.get(id=role_id)
|
||||||
user.groups.remove(role.group)
|
user.groups.remove(role.group)
|
||||||
|
|
||||||
AuditService.log_from_request(
|
AuditService.log_from_request(
|
||||||
event_type='role_change',
|
event_type='role_change',
|
||||||
description=f"Role {role.display_name} removed from user {user.email}",
|
description=f"Role {role.display_name} removed from user {user.email}",
|
||||||
@ -218,7 +243,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
content_object=user,
|
content_object=user,
|
||||||
metadata={'role': role.name}
|
metadata={'role': role.name}
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response({'message': f'Role {role.display_name} removed successfully'})
|
return Response({'message': f'Role {role.display_name} removed successfully'})
|
||||||
except Role.DoesNotExist:
|
except Role.DoesNotExist:
|
||||||
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
@ -227,7 +252,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
class RoleViewSet(viewsets.ModelViewSet):
|
class RoleViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Role model.
|
ViewSet for Role model.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- List/Retrieve: Authenticated users
|
- List/Retrieve: Authenticated users
|
||||||
- Create/Update/Delete: PX Admins only
|
- Create/Update/Delete: PX Admins only
|
||||||
@ -239,7 +264,7 @@ class RoleViewSet(viewsets.ModelViewSet):
|
|||||||
search_fields = ['name', 'display_name', 'description']
|
search_fields = ['name', 'display_name', 'description']
|
||||||
ordering_fields = ['level', 'name']
|
ordering_fields = ['level', 'name']
|
||||||
ordering = ['-level', 'name']
|
ordering = ['-level', 'name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return super().get_queryset().select_related('group')
|
return super().get_queryset().select_related('group')
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 11:25
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 11:19
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from django.views.decorators.http import require_http_methods
|
|||||||
|
|
||||||
from apps.complaints.models import Complaint, ComplaintSource, Inquiry
|
from apps.complaints.models import Complaint, ComplaintSource, Inquiry
|
||||||
from apps.core.services import AuditService
|
from apps.core.services import AuditService
|
||||||
from apps.organizations.models import Department, Hospital, Patient, Physician
|
from apps.organizations.models import Department, Hospital, Patient, Staff
|
||||||
|
|
||||||
from .models import CallCenterInteraction
|
from .models import CallCenterInteraction
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ def interaction_list(request):
|
|||||||
queryset = CallCenterInteraction.objects.select_related(
|
queryset = CallCenterInteraction.objects.select_related(
|
||||||
'patient', 'hospital', 'department', 'agent'
|
'patient', 'hospital', 'department', 'agent'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
@ -32,20 +32,20 @@ def interaction_list(request):
|
|||||||
queryset = queryset.filter(hospital=user.hospital)
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
call_type_filter = request.GET.get('call_type')
|
call_type_filter = request.GET.get('call_type')
|
||||||
if call_type_filter:
|
if call_type_filter:
|
||||||
queryset = queryset.filter(call_type=call_type_filter)
|
queryset = queryset.filter(call_type=call_type_filter)
|
||||||
|
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
is_low_rating = request.GET.get('is_low_rating')
|
is_low_rating = request.GET.get('is_low_rating')
|
||||||
if is_low_rating == 'true':
|
if is_low_rating == 'true':
|
||||||
queryset = queryset.filter(is_low_rating=True)
|
queryset = queryset.filter(is_low_rating=True)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
@ -54,30 +54,30 @@ def interaction_list(request):
|
|||||||
Q(caller_name__icontains=search_query) |
|
Q(caller_name__icontains=search_query) |
|
||||||
Q(patient__mrn__icontains=search_query)
|
Q(patient__mrn__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Date range
|
# Date range
|
||||||
date_from = request.GET.get('date_from')
|
date_from = request.GET.get('date_from')
|
||||||
if date_from:
|
if date_from:
|
||||||
queryset = queryset.filter(call_started_at__gte=date_from)
|
queryset = queryset.filter(call_started_at__gte=date_from)
|
||||||
|
|
||||||
date_to = request.GET.get('date_to')
|
date_to = request.GET.get('date_to')
|
||||||
if date_to:
|
if date_to:
|
||||||
queryset = queryset.filter(call_started_at__lte=date_to)
|
queryset = queryset.filter(call_started_at__lte=date_to)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
queryset = queryset.order_by('-call_started_at')
|
queryset = queryset.order_by('-call_started_at')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
stats = {
|
stats = {
|
||||||
'total': queryset.count(),
|
'total': queryset.count(),
|
||||||
@ -86,7 +86,7 @@ def interaction_list(request):
|
|||||||
avg=Avg('satisfaction_rating')
|
avg=Avg('satisfaction_rating')
|
||||||
)['avg'] or 0,
|
)['avg'] or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'interactions': page_obj.object_list,
|
'interactions': page_obj.object_list,
|
||||||
@ -94,7 +94,7 @@ def interaction_list(request):
|
|||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'callcenter/interaction_list.html', context)
|
return render(request, 'callcenter/interaction_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -107,11 +107,11 @@ def interaction_detail(request, pk):
|
|||||||
),
|
),
|
||||||
pk=pk
|
pk=pk
|
||||||
)
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'interaction': interaction,
|
'interaction': interaction,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'callcenter/interaction_detail.html', context)
|
return render(request, 'callcenter/interaction_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -124,7 +124,7 @@ def interaction_detail(request, pk):
|
|||||||
def create_complaint(request):
|
def create_complaint(request):
|
||||||
"""
|
"""
|
||||||
Create complaint from call center interaction.
|
Create complaint from call center interaction.
|
||||||
|
|
||||||
Call center staff can create complaints on behalf of patients/callers.
|
Call center staff can create complaints on behalf of patients/callers.
|
||||||
"""
|
"""
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@ -133,8 +133,8 @@ def create_complaint(request):
|
|||||||
patient_id = request.POST.get('patient_id', None)
|
patient_id = request.POST.get('patient_id', None)
|
||||||
hospital_id = request.POST.get('hospital_id')
|
hospital_id = request.POST.get('hospital_id')
|
||||||
department_id = request.POST.get('department_id', None)
|
department_id = request.POST.get('department_id', None)
|
||||||
physician_id = request.POST.get('physician_id', None)
|
staff_id = request.POST.get('staff_id', None)
|
||||||
|
|
||||||
title = request.POST.get('title')
|
title = request.POST.get('title')
|
||||||
description = request.POST.get('description')
|
description = request.POST.get('description')
|
||||||
category = request.POST.get('category')
|
category = request.POST.get('category')
|
||||||
@ -142,28 +142,28 @@ def create_complaint(request):
|
|||||||
priority = request.POST.get('priority')
|
priority = request.POST.get('priority')
|
||||||
severity = request.POST.get('severity')
|
severity = request.POST.get('severity')
|
||||||
encounter_id = request.POST.get('encounter_id', '')
|
encounter_id = request.POST.get('encounter_id', '')
|
||||||
|
|
||||||
# Call center specific fields
|
# Call center specific fields
|
||||||
caller_name = request.POST.get('caller_name', '')
|
caller_name = request.POST.get('caller_name', '')
|
||||||
caller_phone = request.POST.get('caller_phone', '')
|
caller_phone = request.POST.get('caller_phone', '')
|
||||||
caller_relationship = request.POST.get('caller_relationship', 'patient')
|
caller_relationship = request.POST.get('caller_relationship', 'patient')
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
if not all([hospital_id, title, description, category, priority, severity]):
|
if not all([hospital_id, title, description, category, priority, severity]):
|
||||||
messages.error(request, "Please fill in all required fields.")
|
messages.error(request, "Please fill in all required fields.")
|
||||||
return redirect('callcenter:create_complaint')
|
return redirect('callcenter:create_complaint')
|
||||||
|
|
||||||
# If no patient selected, we need caller info
|
# If no patient selected, we need caller info
|
||||||
if not patient_id and not caller_name:
|
if not patient_id and not caller_name:
|
||||||
messages.error(request, "Please provide either patient or caller information.")
|
messages.error(request, "Please provide either patient or caller information.")
|
||||||
return redirect('callcenter:create_complaint')
|
return redirect('callcenter:create_complaint')
|
||||||
|
|
||||||
# Create complaint
|
# Create complaint
|
||||||
complaint = Complaint.objects.create(
|
complaint = Complaint.objects.create(
|
||||||
patient_id=patient_id if patient_id else None,
|
patient_id=patient_id if patient_id else None,
|
||||||
hospital_id=hospital_id,
|
hospital_id=hospital_id,
|
||||||
department_id=department_id if department_id else None,
|
department_id=department_id if department_id else None,
|
||||||
physician_id=physician_id if physician_id else None,
|
staff_id=staff_id if staff_id else None,
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
category=category,
|
category=category,
|
||||||
@ -173,7 +173,7 @@ def create_complaint(request):
|
|||||||
source=ComplaintSource.CALL_CENTER,
|
source=ComplaintSource.CALL_CENTER,
|
||||||
encounter_id=encounter_id,
|
encounter_id=encounter_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create call center interaction record
|
# Create call center interaction record
|
||||||
CallCenterInteraction.objects.create(
|
CallCenterInteraction.objects.create(
|
||||||
patient_id=patient_id if patient_id else None,
|
patient_id=patient_id if patient_id else None,
|
||||||
@ -192,7 +192,7 @@ def create_complaint(request):
|
|||||||
'severity': severity,
|
'severity': severity,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='complaint_created',
|
event_type='complaint_created',
|
||||||
@ -206,23 +206,23 @@ def create_complaint(request):
|
|||||||
'caller_name': caller_name,
|
'caller_name': caller_name,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, f"Complaint #{complaint.id} created successfully.")
|
messages.success(request, f"Complaint #{complaint.id} created successfully.")
|
||||||
return redirect('callcenter:complaint_success', pk=complaint.id)
|
return redirect('callcenter:complaint_success', pk=complaint.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"Error creating complaint: {str(e)}")
|
messages.error(request, f"Error creating complaint: {str(e)}")
|
||||||
return redirect('callcenter:create_complaint')
|
return redirect('callcenter:create_complaint')
|
||||||
|
|
||||||
# GET request - show form
|
# GET request - show form
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not request.user.is_px_admin() and request.user.hospital:
|
if not request.user.is_px_admin() and request.user.hospital:
|
||||||
hospitals = hospitals.filter(id=request.user.hospital.id)
|
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'callcenter/complaint_form.html', context)
|
return render(request, 'callcenter/complaint_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -230,11 +230,11 @@ def create_complaint(request):
|
|||||||
def complaint_success(request, pk):
|
def complaint_success(request, pk):
|
||||||
"""Success page after creating complaint"""
|
"""Success page after creating complaint"""
|
||||||
complaint = get_object_or_404(Complaint, pk=pk)
|
complaint = get_object_or_404(Complaint, pk=pk)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'complaint': complaint,
|
'complaint': complaint,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'callcenter/complaint_success.html', context)
|
return render(request, 'callcenter/complaint_success.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -244,9 +244,9 @@ def complaint_list(request):
|
|||||||
queryset = Complaint.objects.filter(
|
queryset = Complaint.objects.filter(
|
||||||
source=ComplaintSource.CALL_CENTER
|
source=ComplaintSource.CALL_CENTER
|
||||||
).select_related(
|
).select_related(
|
||||||
'patient', 'hospital', 'department', 'physician', 'assigned_to'
|
'patient', 'hospital', 'department', 'staff', 'assigned_to'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
@ -255,20 +255,20 @@ def complaint_list(request):
|
|||||||
queryset = queryset.filter(hospital=user.hospital)
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
status_filter = request.GET.get('status')
|
status_filter = request.GET.get('status')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
severity_filter = request.GET.get('severity')
|
severity_filter = request.GET.get('severity')
|
||||||
if severity_filter:
|
if severity_filter:
|
||||||
queryset = queryset.filter(severity=severity_filter)
|
queryset = queryset.filter(severity=severity_filter)
|
||||||
|
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
@ -277,21 +277,21 @@ def complaint_list(request):
|
|||||||
Q(description__icontains=search_query) |
|
Q(description__icontains=search_query) |
|
||||||
Q(patient__mrn__icontains=search_query)
|
Q(patient__mrn__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
queryset = queryset.order_by('-created_at')
|
queryset = queryset.order_by('-created_at')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
stats = {
|
stats = {
|
||||||
'total': queryset.count(),
|
'total': queryset.count(),
|
||||||
@ -299,7 +299,7 @@ def complaint_list(request):
|
|||||||
'in_progress': queryset.filter(status='in_progress').count(),
|
'in_progress': queryset.filter(status='in_progress').count(),
|
||||||
'resolved': queryset.filter(status='resolved').count(),
|
'resolved': queryset.filter(status='resolved').count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'complaints': page_obj.object_list,
|
'complaints': page_obj.object_list,
|
||||||
@ -307,7 +307,7 @@ def complaint_list(request):
|
|||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'callcenter/complaint_list.html', context)
|
return render(request, 'callcenter/complaint_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -320,7 +320,7 @@ def complaint_list(request):
|
|||||||
def create_inquiry(request):
|
def create_inquiry(request):
|
||||||
"""
|
"""
|
||||||
Create inquiry from call center interaction.
|
Create inquiry from call center interaction.
|
||||||
|
|
||||||
Call center staff can create inquiries for general questions/requests.
|
Call center staff can create inquiries for general questions/requests.
|
||||||
"""
|
"""
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@ -329,29 +329,29 @@ def create_inquiry(request):
|
|||||||
patient_id = request.POST.get('patient_id', None)
|
patient_id = request.POST.get('patient_id', None)
|
||||||
hospital_id = request.POST.get('hospital_id')
|
hospital_id = request.POST.get('hospital_id')
|
||||||
department_id = request.POST.get('department_id', None)
|
department_id = request.POST.get('department_id', None)
|
||||||
|
|
||||||
subject = request.POST.get('subject')
|
subject = request.POST.get('subject')
|
||||||
message = request.POST.get('message')
|
message = request.POST.get('message')
|
||||||
category = request.POST.get('category')
|
category = request.POST.get('category')
|
||||||
|
|
||||||
# Contact info (if no patient)
|
# Contact info (if no patient)
|
||||||
contact_name = request.POST.get('contact_name', '')
|
contact_name = request.POST.get('contact_name', '')
|
||||||
contact_phone = request.POST.get('contact_phone', '')
|
contact_phone = request.POST.get('contact_phone', '')
|
||||||
contact_email = request.POST.get('contact_email', '')
|
contact_email = request.POST.get('contact_email', '')
|
||||||
|
|
||||||
# Call center specific
|
# Call center specific
|
||||||
caller_relationship = request.POST.get('caller_relationship', 'patient')
|
caller_relationship = request.POST.get('caller_relationship', 'patient')
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
if not all([hospital_id, subject, message, category]):
|
if not all([hospital_id, subject, message, category]):
|
||||||
messages.error(request, "Please fill in all required fields.")
|
messages.error(request, "Please fill in all required fields.")
|
||||||
return redirect('callcenter:create_inquiry')
|
return redirect('callcenter:create_inquiry')
|
||||||
|
|
||||||
# If no patient, need contact info
|
# If no patient, need contact info
|
||||||
if not patient_id and not contact_name:
|
if not patient_id and not contact_name:
|
||||||
messages.error(request, "Please provide either patient or contact information.")
|
messages.error(request, "Please provide either patient or contact information.")
|
||||||
return redirect('callcenter:create_inquiry')
|
return redirect('callcenter:create_inquiry')
|
||||||
|
|
||||||
# Create inquiry
|
# Create inquiry
|
||||||
inquiry = Inquiry.objects.create(
|
inquiry = Inquiry.objects.create(
|
||||||
patient_id=patient_id if patient_id else None,
|
patient_id=patient_id if patient_id else None,
|
||||||
@ -364,7 +364,7 @@ def create_inquiry(request):
|
|||||||
contact_phone=contact_phone,
|
contact_phone=contact_phone,
|
||||||
contact_email=contact_email,
|
contact_email=contact_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create call center interaction record
|
# Create call center interaction record
|
||||||
CallCenterInteraction.objects.create(
|
CallCenterInteraction.objects.create(
|
||||||
patient_id=patient_id if patient_id else None,
|
patient_id=patient_id if patient_id else None,
|
||||||
@ -382,7 +382,7 @@ def create_inquiry(request):
|
|||||||
'category': category,
|
'category': category,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='inquiry_created',
|
event_type='inquiry_created',
|
||||||
@ -395,23 +395,23 @@ def create_inquiry(request):
|
|||||||
'contact_name': contact_name,
|
'contact_name': contact_name,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, f"Inquiry #{inquiry.id} created successfully.")
|
messages.success(request, f"Inquiry #{inquiry.id} created successfully.")
|
||||||
return redirect('callcenter:inquiry_success', pk=inquiry.id)
|
return redirect('callcenter:inquiry_success', pk=inquiry.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"Error creating inquiry: {str(e)}")
|
messages.error(request, f"Error creating inquiry: {str(e)}")
|
||||||
return redirect('callcenter:create_inquiry')
|
return redirect('callcenter:create_inquiry')
|
||||||
|
|
||||||
# GET request - show form
|
# GET request - show form
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not request.user.is_px_admin() and request.user.hospital:
|
if not request.user.is_px_admin() and request.user.hospital:
|
||||||
hospitals = hospitals.filter(id=request.user.hospital.id)
|
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'callcenter/inquiry_form.html', context)
|
return render(request, 'callcenter/inquiry_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -419,11 +419,11 @@ def create_inquiry(request):
|
|||||||
def inquiry_success(request, pk):
|
def inquiry_success(request, pk):
|
||||||
"""Success page after creating inquiry"""
|
"""Success page after creating inquiry"""
|
||||||
inquiry = get_object_or_404(Inquiry, pk=pk)
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'inquiry': inquiry,
|
'inquiry': inquiry,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'callcenter/inquiry_success.html', context)
|
return render(request, 'callcenter/inquiry_success.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -433,7 +433,7 @@ def inquiry_list(request):
|
|||||||
queryset = Inquiry.objects.select_related(
|
queryset = Inquiry.objects.select_related(
|
||||||
'patient', 'hospital', 'department', 'assigned_to', 'responded_by'
|
'patient', 'hospital', 'department', 'assigned_to', 'responded_by'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
@ -442,20 +442,20 @@ def inquiry_list(request):
|
|||||||
queryset = queryset.filter(hospital=user.hospital)
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
status_filter = request.GET.get('status')
|
status_filter = request.GET.get('status')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
category_filter = request.GET.get('category')
|
category_filter = request.GET.get('category')
|
||||||
if category_filter:
|
if category_filter:
|
||||||
queryset = queryset.filter(category=category_filter)
|
queryset = queryset.filter(category=category_filter)
|
||||||
|
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
@ -464,21 +464,21 @@ def inquiry_list(request):
|
|||||||
Q(message__icontains=search_query) |
|
Q(message__icontains=search_query) |
|
||||||
Q(contact_name__icontains=search_query)
|
Q(contact_name__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
queryset = queryset.order_by('-created_at')
|
queryset = queryset.order_by('-created_at')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
stats = {
|
stats = {
|
||||||
'total': queryset.count(),
|
'total': queryset.count(),
|
||||||
@ -486,7 +486,7 @@ def inquiry_list(request):
|
|||||||
'in_progress': queryset.filter(status='in_progress').count(),
|
'in_progress': queryset.filter(status='in_progress').count(),
|
||||||
'resolved': queryset.filter(status='resolved').count(),
|
'resolved': queryset.filter(status='resolved').count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'inquiries': page_obj.object_list,
|
'inquiries': page_obj.object_list,
|
||||||
@ -494,7 +494,7 @@ def inquiry_list(request):
|
|||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'callcenter/inquiry_list.html', context)
|
return render(request, 'callcenter/inquiry_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -508,25 +508,31 @@ def get_departments_by_hospital(request):
|
|||||||
hospital_id = request.GET.get('hospital_id')
|
hospital_id = request.GET.get('hospital_id')
|
||||||
if not hospital_id:
|
if not hospital_id:
|
||||||
return JsonResponse({'departments': []})
|
return JsonResponse({'departments': []})
|
||||||
|
|
||||||
departments = Department.objects.filter(
|
departments = Department.objects.filter(
|
||||||
hospital_id=hospital_id,
|
hospital_id=hospital_id,
|
||||||
status='active'
|
status='active'
|
||||||
|
<<<<<<< HEAD
|
||||||
).values('id', 'name', 'name_ar')
|
).values('id', 'name', 'name_ar')
|
||||||
|
|
||||||
|
=======
|
||||||
|
).values('id', 'name_en', 'name_ar')
|
||||||
|
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
return JsonResponse({'departments': list(departments)})
|
return JsonResponse({'departments': list(departments)})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def get_physicians_by_hospital(request):
|
def get_staff_by_hospital(request):
|
||||||
"""Get physicians for a hospital (AJAX)"""
|
"""Get staff for a hospital (AJAX)"""
|
||||||
hospital_id = request.GET.get('hospital_id')
|
hospital_id = request.GET.get('hospital_id')
|
||||||
if not hospital_id:
|
if not hospital_id:
|
||||||
return JsonResponse({'physicians': []})
|
return JsonResponse({'staff': []})
|
||||||
|
|
||||||
physicians = Physician.objects.filter(
|
staff_members = Staff.objects.filter(
|
||||||
hospital_id=hospital_id,
|
hospital_id=hospital_id,
|
||||||
status='active'
|
status='active'
|
||||||
|
<<<<<<< HEAD
|
||||||
).values('id', 'first_name', 'last_name', 'specialization')
|
).values('id', 'first_name', 'last_name', 'specialization')
|
||||||
|
|
||||||
# Format physician names
|
# Format physician names
|
||||||
@ -535,11 +541,22 @@ def get_physicians_by_hospital(request):
|
|||||||
'id': str(p['id']),
|
'id': str(p['id']),
|
||||||
'name': f"Dr. {p['first_name']} {p['last_name']}",
|
'name': f"Dr. {p['first_name']} {p['last_name']}",
|
||||||
'specialty': p['specialization']
|
'specialty': p['specialization']
|
||||||
|
=======
|
||||||
|
).values('id', 'first_name', 'last_name', 'staff_type', 'specialization')
|
||||||
|
|
||||||
|
# Format staff names
|
||||||
|
staff_list = [
|
||||||
|
{
|
||||||
|
'id': str(s['id']),
|
||||||
|
'name': f"Dr. {s['first_name']} {s['last_name']}" if s['staff_type'] == 'physician' else f"{s['first_name']} {s['last_name']}",
|
||||||
|
'staff_type': s['staff_type'],
|
||||||
|
'specialization': s['specialization']
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
}
|
}
|
||||||
for p in physicians
|
for s in staff_members
|
||||||
]
|
]
|
||||||
|
|
||||||
return JsonResponse({'physicians': physicians_list})
|
return JsonResponse({'staff': staff_list})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -547,10 +564,10 @@ def search_patients(request):
|
|||||||
"""Search patients by MRN or name (AJAX)"""
|
"""Search patients by MRN or name (AJAX)"""
|
||||||
query = request.GET.get('q', '')
|
query = request.GET.get('q', '')
|
||||||
hospital_id = request.GET.get('hospital_id', None)
|
hospital_id = request.GET.get('hospital_id', None)
|
||||||
|
|
||||||
if len(query) < 2:
|
if len(query) < 2:
|
||||||
return JsonResponse({'patients': []})
|
return JsonResponse({'patients': []})
|
||||||
|
|
||||||
patients = Patient.objects.filter(
|
patients = Patient.objects.filter(
|
||||||
Q(mrn__icontains=query) |
|
Q(mrn__icontains=query) |
|
||||||
Q(first_name__icontains=query) |
|
Q(first_name__icontains=query) |
|
||||||
@ -558,12 +575,12 @@ def search_patients(request):
|
|||||||
Q(national_id__icontains=query) |
|
Q(national_id__icontains=query) |
|
||||||
Q(phone__icontains=query)
|
Q(phone__icontains=query)
|
||||||
)
|
)
|
||||||
|
|
||||||
if hospital_id:
|
if hospital_id:
|
||||||
patients = patients.filter(hospital_id=hospital_id)
|
patients = patients.filter(hospital_id=hospital_id)
|
||||||
|
|
||||||
patients = patients[:20]
|
patients = patients[:20]
|
||||||
|
|
||||||
results = [
|
results = [
|
||||||
{
|
{
|
||||||
'id': str(p.id),
|
'id': str(p.id),
|
||||||
@ -575,5 +592,5 @@ def search_patients(request):
|
|||||||
}
|
}
|
||||||
for p in patients
|
for p in patients
|
||||||
]
|
]
|
||||||
|
|
||||||
return JsonResponse({'patients': results})
|
return JsonResponse({'patients': results})
|
||||||
|
|||||||
@ -7,19 +7,19 @@ urlpatterns = [
|
|||||||
# Interactions
|
# Interactions
|
||||||
path('interactions/', ui_views.interaction_list, name='interaction_list'),
|
path('interactions/', ui_views.interaction_list, name='interaction_list'),
|
||||||
path('interactions/<uuid:pk>/', ui_views.interaction_detail, name='interaction_detail'),
|
path('interactions/<uuid:pk>/', ui_views.interaction_detail, name='interaction_detail'),
|
||||||
|
|
||||||
# Complaints
|
# Complaints
|
||||||
path('complaints/', ui_views.complaint_list, name='complaint_list'),
|
path('complaints/', ui_views.complaint_list, name='complaint_list'),
|
||||||
path('complaints/create/', ui_views.create_complaint, name='create_complaint'),
|
path('complaints/create/', ui_views.create_complaint, name='create_complaint'),
|
||||||
path('complaints/<uuid:pk>/success/', ui_views.complaint_success, name='complaint_success'),
|
path('complaints/<uuid:pk>/success/', ui_views.complaint_success, name='complaint_success'),
|
||||||
|
|
||||||
# Inquiries
|
# Inquiries
|
||||||
path('inquiries/', ui_views.inquiry_list, name='inquiry_list'),
|
path('inquiries/', ui_views.inquiry_list, name='inquiry_list'),
|
||||||
path('inquiries/create/', ui_views.create_inquiry, name='create_inquiry'),
|
path('inquiries/create/', ui_views.create_inquiry, name='create_inquiry'),
|
||||||
path('inquiries/<uuid:pk>/success/', ui_views.inquiry_success, name='inquiry_success'),
|
path('inquiries/<uuid:pk>/success/', ui_views.inquiry_success, name='inquiry_success'),
|
||||||
|
|
||||||
# AJAX Helpers
|
# AJAX Helpers
|
||||||
path('ajax/departments/', ui_views.get_departments_by_hospital, name='ajax_departments'),
|
path('ajax/departments/', ui_views.get_departments_by_hospital, name='ajax_departments'),
|
||||||
path('ajax/physicians/', ui_views.get_physicians_by_hospital, name='ajax_physicians'),
|
path('ajax/physicians/', ui_views.get_staff_by_hospital, name='ajax_physicians'),
|
||||||
path('ajax/patients/', ui_views.search_patients, name='ajax_patients'),
|
path('ajax/patients/', ui_views.search_patients, name='ajax_patients'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -52,13 +52,13 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
date_hierarchy = 'created_at'
|
date_hierarchy = 'created_at'
|
||||||
inlines = [ComplaintUpdateInline, ComplaintAttachmentInline]
|
inlines = [ComplaintUpdateInline, ComplaintAttachmentInline]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Patient & Encounter', {
|
('Patient & Encounter', {
|
||||||
'fields': ('patient', 'encounter_id')
|
'fields': ('patient', 'encounter_id')
|
||||||
}),
|
}),
|
||||||
('Organization', {
|
('Organization', {
|
||||||
'fields': ('hospital', 'department', 'physician')
|
'fields': ('hospital', 'department', 'staff')
|
||||||
}),
|
}),
|
||||||
('Complaint Details', {
|
('Complaint Details', {
|
||||||
'fields': ('title', 'description', 'category', 'subcategory')
|
'fields': ('title', 'description', 'category', 'subcategory')
|
||||||
@ -83,25 +83,25 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = [
|
readonly_fields = [
|
||||||
'assigned_at', 'reminder_sent_at', 'escalated_at',
|
'assigned_at', 'reminder_sent_at', 'escalated_at',
|
||||||
'resolved_at', 'closed_at', 'resolution_survey_sent_at',
|
'resolved_at', 'closed_at', 'resolution_survey_sent_at',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related(
|
return qs.select_related(
|
||||||
'patient', 'hospital', 'department', 'physician',
|
'patient', 'hospital', 'department', 'staff',
|
||||||
'assigned_to', 'resolved_by', 'closed_by', 'resolution_survey'
|
'assigned_to', 'resolved_by', 'closed_by', 'resolution_survey'
|
||||||
)
|
)
|
||||||
|
|
||||||
def title_preview(self, obj):
|
def title_preview(self, obj):
|
||||||
"""Show preview of title"""
|
"""Show preview of title"""
|
||||||
return obj.title[:60] + '...' if len(obj.title) > 60 else obj.title
|
return obj.title[:60] + '...' if len(obj.title) > 60 else obj.title
|
||||||
title_preview.short_description = 'Title'
|
title_preview.short_description = 'Title'
|
||||||
|
|
||||||
def severity_badge(self, obj):
|
def severity_badge(self, obj):
|
||||||
"""Display severity with color badge"""
|
"""Display severity with color badge"""
|
||||||
colors = {
|
colors = {
|
||||||
@ -117,7 +117,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
obj.get_severity_display()
|
obj.get_severity_display()
|
||||||
)
|
)
|
||||||
severity_badge.short_description = 'Severity'
|
severity_badge.short_description = 'Severity'
|
||||||
|
|
||||||
def status_badge(self, obj):
|
def status_badge(self, obj):
|
||||||
"""Display status with color badge"""
|
"""Display status with color badge"""
|
||||||
colors = {
|
colors = {
|
||||||
@ -134,16 +134,16 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
obj.get_status_display()
|
obj.get_status_display()
|
||||||
)
|
)
|
||||||
status_badge.short_description = 'Status'
|
status_badge.short_description = 'Status'
|
||||||
|
|
||||||
def sla_indicator(self, obj):
|
def sla_indicator(self, obj):
|
||||||
"""Display SLA status"""
|
"""Display SLA status"""
|
||||||
if obj.is_overdue:
|
if obj.is_overdue:
|
||||||
return format_html('<span class="badge bg-danger">OVERDUE</span>')
|
return format_html('<span class="badge bg-danger">OVERDUE</span>')
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
time_remaining = obj.due_at - timezone.now()
|
time_remaining = obj.due_at - timezone.now()
|
||||||
hours_remaining = time_remaining.total_seconds() / 3600
|
hours_remaining = time_remaining.total_seconds() / 3600
|
||||||
|
|
||||||
if hours_remaining < 4:
|
if hours_remaining < 4:
|
||||||
return format_html('<span class="badge bg-warning">DUE SOON</span>')
|
return format_html('<span class="badge bg-warning">DUE SOON</span>')
|
||||||
else:
|
else:
|
||||||
@ -158,7 +158,7 @@ class ComplaintAttachmentAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ['file_type', 'created_at']
|
list_filter = ['file_type', 'created_at']
|
||||||
search_fields = ['filename', 'description', 'complaint__title']
|
search_fields = ['filename', 'description', 'complaint__title']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'fields': ('complaint', 'file', 'filename', 'file_type', 'file_size')
|
'fields': ('complaint', 'file', 'filename', 'file_type', 'file_size')
|
||||||
@ -170,9 +170,9 @@ class ComplaintAttachmentAdmin(admin.ModelAdmin):
|
|||||||
'fields': ('created_at', 'updated_at')
|
'fields': ('created_at', 'updated_at')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['file_size', 'created_at', 'updated_at']
|
readonly_fields = ['file_size', 'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('complaint', 'uploaded_by')
|
return qs.select_related('complaint', 'uploaded_by')
|
||||||
@ -185,7 +185,7 @@ class ComplaintUpdateAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ['update_type', 'created_at']
|
list_filter = ['update_type', 'created_at']
|
||||||
search_fields = ['message', 'complaint__title']
|
search_fields = ['message', 'complaint__title']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'fields': ('complaint', 'update_type', 'message')
|
'fields': ('complaint', 'update_type', 'message')
|
||||||
@ -201,13 +201,13 @@ class ComplaintUpdateAdmin(admin.ModelAdmin):
|
|||||||
'fields': ('created_at', 'updated_at')
|
'fields': ('created_at', 'updated_at')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('complaint', 'created_by')
|
return qs.select_related('complaint', 'created_by')
|
||||||
|
|
||||||
def message_preview(self, obj):
|
def message_preview(self, obj):
|
||||||
"""Show preview of message"""
|
"""Show preview of message"""
|
||||||
return obj.message[:100] + '...' if len(obj.message) > 100 else obj.message
|
return obj.message[:100] + '...' if len(obj.message) > 100 else obj.message
|
||||||
@ -227,7 +227,7 @@ class InquiryAdmin(admin.ModelAdmin):
|
|||||||
'patient__mrn', 'patient__first_name', 'patient__last_name'
|
'patient__mrn', 'patient__first_name', 'patient__last_name'
|
||||||
]
|
]
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Patient Information', {
|
('Patient Information', {
|
||||||
'fields': ('patient',)
|
'fields': ('patient',)
|
||||||
@ -252,16 +252,16 @@ class InquiryAdmin(admin.ModelAdmin):
|
|||||||
'fields': ('created_at', 'updated_at')
|
'fields': ('created_at', 'updated_at')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['responded_at', 'created_at', 'updated_at']
|
readonly_fields = ['responded_at', 'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related(
|
return qs.select_related(
|
||||||
'patient', 'hospital', 'department',
|
'patient', 'hospital', 'department',
|
||||||
'assigned_to', 'responded_by'
|
'assigned_to', 'responded_by'
|
||||||
)
|
)
|
||||||
|
|
||||||
def subject_preview(self, obj):
|
def subject_preview(self, obj):
|
||||||
"""Show preview of subject"""
|
"""Show preview of subject"""
|
||||||
return obj.subject[:60] + '...' if len(obj.subject) > 60 else obj.subject
|
return obj.subject[:60] + '...' if len(obj.subject) > 60 else obj.subject
|
||||||
@ -278,7 +278,7 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ['hospital', 'severity', 'priority', 'is_active']
|
list_filter = ['hospital', 'severity', 'priority', 'is_active']
|
||||||
search_fields = ['hospital__name_en', 'hospital__name_ar']
|
search_fields = ['hospital__name_en', 'hospital__name_ar']
|
||||||
ordering = ['hospital', 'severity', 'priority']
|
ordering = ['hospital', 'severity', 'priority']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Hospital', {
|
('Hospital', {
|
||||||
'fields': ('hospital',)
|
'fields': ('hospital',)
|
||||||
@ -297,9 +297,9 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital')
|
return qs.select_related('hospital')
|
||||||
@ -309,16 +309,16 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin):
|
|||||||
class ComplaintCategoryAdmin(admin.ModelAdmin):
|
class ComplaintCategoryAdmin(admin.ModelAdmin):
|
||||||
"""Complaint Category admin"""
|
"""Complaint Category admin"""
|
||||||
list_display = [
|
list_display = [
|
||||||
'name_en', 'code', 'hospital', 'parent',
|
'name_en', 'code', 'hospitals_display', 'parent',
|
||||||
'order', 'is_active'
|
'order', 'is_active'
|
||||||
]
|
]
|
||||||
list_filter = ['hospital', 'is_active', 'parent']
|
list_filter = ['is_active', 'parent']
|
||||||
search_fields = ['name_en', 'name_ar', 'code', 'description_en']
|
search_fields = ['name_en', 'name_ar', 'code', 'description_en']
|
||||||
ordering = ['hospital', 'order', 'name_en']
|
ordering = ['order', 'name_en']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Hospital', {
|
('Hospitals', {
|
||||||
'fields': ('hospital',)
|
'fields': ('hospitals',)
|
||||||
}),
|
}),
|
||||||
('Category Details', {
|
('Category Details', {
|
||||||
'fields': ('code', 'name_en', 'name_ar')
|
'fields': ('code', 'name_en', 'name_ar')
|
||||||
@ -338,12 +338,24 @@ class ComplaintCategoryAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
filter_horizontal = ['hospitals']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital', 'parent')
|
return qs.select_related('parent').prefetch_related('hospitals')
|
||||||
|
|
||||||
|
def hospitals_display(self, obj):
|
||||||
|
"""Display hospitals for category"""
|
||||||
|
hospital_count = obj.hospitals.count()
|
||||||
|
if hospital_count == 0:
|
||||||
|
return 'System-wide'
|
||||||
|
elif hospital_count == 1:
|
||||||
|
return obj.hospitals.first().name
|
||||||
|
else:
|
||||||
|
return f'{hospital_count} hospitals'
|
||||||
|
hospitals_display.short_description = 'Hospitals'
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EscalationRule)
|
@admin.register(EscalationRule)
|
||||||
@ -359,7 +371,7 @@ class EscalationRuleAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
search_fields = ['name', 'description', 'hospital__name_en']
|
search_fields = ['name', 'description', 'hospital__name_en']
|
||||||
ordering = ['hospital', 'order']
|
ordering = ['hospital', 'order']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Hospital', {
|
('Hospital', {
|
||||||
'fields': ('hospital',)
|
'fields': ('hospital',)
|
||||||
@ -385,9 +397,9 @@ class EscalationRuleAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital', 'escalate_to_user')
|
return qs.select_related('hospital', 'escalate_to_user')
|
||||||
@ -406,7 +418,7 @@ class ComplaintThresholdAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
search_fields = ['hospital__name_en', 'hospital__name_ar']
|
search_fields = ['hospital__name_en', 'hospital__name_ar']
|
||||||
ordering = ['hospital', 'threshold_type']
|
ordering = ['hospital', 'threshold_type']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Hospital', {
|
('Hospital', {
|
||||||
'fields': ('hospital',)
|
'fields': ('hospital',)
|
||||||
@ -425,13 +437,13 @@ class ComplaintThresholdAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital')
|
return qs.select_related('hospital')
|
||||||
|
|
||||||
def comparison_display(self, obj):
|
def comparison_display(self, obj):
|
||||||
"""Display comparison operator"""
|
"""Display comparison operator"""
|
||||||
return f"{obj.get_comparison_operator_display()}"
|
return f"{obj.get_comparison_operator_display()}"
|
||||||
|
|||||||
335
apps/complaints/forms.py
Normal file
335
apps/complaints/forms.py
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
"""
|
||||||
|
Complaints forms
|
||||||
|
"""
|
||||||
|
from django import forms
|
||||||
|
from django.db import models
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import validate_email
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.complaints.models import (
|
||||||
|
Complaint,
|
||||||
|
ComplaintCategory,
|
||||||
|
ComplaintSource,
|
||||||
|
ComplaintStatus,
|
||||||
|
)
|
||||||
|
from apps.core.models import PriorityChoices, SeverityChoices
|
||||||
|
from apps.organizations.models import Department, Hospital
|
||||||
|
|
||||||
|
|
||||||
|
class MultiFileInput(forms.FileInput):
|
||||||
|
"""
|
||||||
|
Custom FileInput widget that supports multiple file uploads.
|
||||||
|
|
||||||
|
Unlike the standard FileInput which only supports single files,
|
||||||
|
this widget allows users to upload multiple files at once.
|
||||||
|
"""
|
||||||
|
def __init__(self, attrs=None):
|
||||||
|
# Call parent's __init__ first to avoid Django's 'multiple' check
|
||||||
|
super().__init__(attrs)
|
||||||
|
# Add 'multiple' attribute after initialization
|
||||||
|
self.attrs['multiple'] = 'multiple'
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""
|
||||||
|
Get all uploaded files for the given field name.
|
||||||
|
|
||||||
|
Returns a list of uploaded files instead of a single file.
|
||||||
|
"""
|
||||||
|
if name in files:
|
||||||
|
return files.getlist(name)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class PublicComplaintForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Simplified public complaint submission form.
|
||||||
|
|
||||||
|
Key changes for AI-powered classification:
|
||||||
|
- Fewer required fields (simplified for public users)
|
||||||
|
- Severity and priority removed (AI will determine these automatically)
|
||||||
|
- Only essential information collected
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Contact Information
|
||||||
|
name = forms.CharField(
|
||||||
|
label=_("Name"),
|
||||||
|
max_length=200,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Your full name')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
email = forms.EmailField(
|
||||||
|
label=_("Email Address"),
|
||||||
|
required=True,
|
||||||
|
widget=forms.EmailInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('your@email.com')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
phone = forms.CharField(
|
||||||
|
label=_("Phone Number"),
|
||||||
|
max_length=20,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Your phone number')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hospital and Department
|
||||||
|
hospital = forms.ModelChoiceField(
|
||||||
|
label=_("Hospital"),
|
||||||
|
queryset=Hospital.objects.filter(status='active').order_by('name'),
|
||||||
|
empty_label=_("Select Hospital"),
|
||||||
|
required=True,
|
||||||
|
widget=forms.Select(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'id': 'hospital_select',
|
||||||
|
'data-action': 'load-departments'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
department = forms.ModelChoiceField(
|
||||||
|
label=_("Department (Optional)"),
|
||||||
|
queryset=Department.objects.none(),
|
||||||
|
empty_label=_("Select Department"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'id': 'department_select'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Complaint Details
|
||||||
|
category = forms.ModelChoiceField(
|
||||||
|
label=_("Complaint Category"),
|
||||||
|
queryset=ComplaintCategory.objects.filter(is_active=True).order_by('name_en'),
|
||||||
|
empty_label=_("Select Category"),
|
||||||
|
required=True,
|
||||||
|
widget=forms.Select(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'id': 'category_select'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
title = forms.CharField(
|
||||||
|
label=_("Complaint Title"),
|
||||||
|
max_length=200,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Brief title of your complaint')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
description = forms.CharField(
|
||||||
|
label=_("Complaint Description"),
|
||||||
|
required=True,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 6,
|
||||||
|
'placeholder': _('Please describe your complaint in detail. Our AI system will analyze and prioritize your complaint accordingly.')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hidden fields - these will be set by the view or AI
|
||||||
|
severity = forms.ChoiceField(
|
||||||
|
label=_("Severity"),
|
||||||
|
choices=SeverityChoices.choices,
|
||||||
|
initial=SeverityChoices.MEDIUM,
|
||||||
|
required=False,
|
||||||
|
widget=forms.HiddenInput()
|
||||||
|
)
|
||||||
|
|
||||||
|
priority = forms.ChoiceField(
|
||||||
|
label=_("Priority"),
|
||||||
|
choices=PriorityChoices.choices,
|
||||||
|
initial=PriorityChoices.MEDIUM,
|
||||||
|
required=False,
|
||||||
|
widget=forms.HiddenInput()
|
||||||
|
)
|
||||||
|
|
||||||
|
# File uploads
|
||||||
|
attachments = forms.FileField(
|
||||||
|
label=_("Attach Documents (Optional)"),
|
||||||
|
required=False,
|
||||||
|
widget=MultiFileInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'accept': 'image/*,.pdf,.doc,.docx'
|
||||||
|
}
|
||||||
|
),
|
||||||
|
help_text=_('You can upload images, PDFs, or Word documents (max 10MB each)')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Complaint
|
||||||
|
fields = [
|
||||||
|
'name', 'email', 'phone', 'hospital', 'department',
|
||||||
|
'category', 'title', 'description', 'severity', 'priority'
|
||||||
|
]
|
||||||
|
# Note: 'attachments' is not in fields because Complaint model doesn't have this field.
|
||||||
|
# Attachments are handled separately via ComplaintAttachment model in the view.
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Check both initial data and POST data for hospital
|
||||||
|
hospital_id = None
|
||||||
|
if 'hospital' in self.initial:
|
||||||
|
hospital_id = self.initial['hospital']
|
||||||
|
elif 'hospital' in self.data:
|
||||||
|
hospital_id = self.data['hospital']
|
||||||
|
|
||||||
|
if hospital_id:
|
||||||
|
# Filter departments
|
||||||
|
self.fields['department'].queryset = Department.objects.filter(
|
||||||
|
hospital_id=hospital_id,
|
||||||
|
status='active'
|
||||||
|
).order_by('name')
|
||||||
|
|
||||||
|
# Filter categories (show hospital-specific first, then system-wide)
|
||||||
|
self.fields['category'].queryset = ComplaintCategory.objects.filter(
|
||||||
|
models.Q(hospital_id=hospital_id) | models.Q(hospital__isnull=True),
|
||||||
|
is_active=True
|
||||||
|
).order_by('hospital', 'order', 'name_en')
|
||||||
|
|
||||||
|
|
||||||
|
def clean_attachments(self):
|
||||||
|
"""Validate file attachments"""
|
||||||
|
files = self.files.getlist('attachments')
|
||||||
|
|
||||||
|
# Check file count
|
||||||
|
if len(files) > 5:
|
||||||
|
raise ValidationError(_('Maximum 5 files allowed'))
|
||||||
|
|
||||||
|
# Check each file
|
||||||
|
for file in files:
|
||||||
|
# Check file size (10MB limit)
|
||||||
|
if file.size > 10 * 1024 * 1024:
|
||||||
|
raise ValidationError(_('File size must be less than 10MB'))
|
||||||
|
|
||||||
|
# Check file type
|
||||||
|
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx']
|
||||||
|
import os
|
||||||
|
ext = os.path.splitext(file.name)[1].lower()
|
||||||
|
if ext not in allowed_extensions:
|
||||||
|
raise ValidationError(_('Allowed file types: JPG, PNG, GIF, PDF, DOC, DOCX'))
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Custom cross-field validation"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
# Basic validation - all required fields are already validated by field definitions
|
||||||
|
# This method is kept for future custom cross-field validation needs
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class PublicInquiryForm(forms.Form):
|
||||||
|
"""Public inquiry submission form (simpler, for general questions)"""
|
||||||
|
|
||||||
|
# Contact Information
|
||||||
|
name = forms.CharField(
|
||||||
|
label=_("Name"),
|
||||||
|
max_length=200,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Your full name')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
phone = forms.CharField(
|
||||||
|
label=_("Phone Number"),
|
||||||
|
max_length=20,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Your phone number')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
email = forms.EmailField(
|
||||||
|
label=_("Email Address"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.EmailInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('your@email.com')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inquiry Details
|
||||||
|
hospital = forms.ModelChoiceField(
|
||||||
|
label=_("Hospital"),
|
||||||
|
queryset=Hospital.objects.filter(status='active').order_by('name'),
|
||||||
|
empty_label=_("Select Hospital"),
|
||||||
|
required=True,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
|
||||||
|
category = forms.ChoiceField(
|
||||||
|
label=_("Inquiry Type"),
|
||||||
|
choices=[
|
||||||
|
('general', 'General Inquiry'),
|
||||||
|
('appointment', 'Appointment Related'),
|
||||||
|
('billing', 'Billing & Insurance'),
|
||||||
|
('medical_records', 'Medical Records'),
|
||||||
|
('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')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
241
apps/complaints/management/commands/load_complaint_categories.py
Normal file
241
apps/complaints/management/commands/load_complaint_categories.py
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from apps.complaints.models import ComplaintCategory
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Load default complaint categories and subcategories'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""Create default complaint categories with subcategories"""
|
||||||
|
|
||||||
|
# Define categories and subcategories
|
||||||
|
categories_data = [
|
||||||
|
{
|
||||||
|
'code': 'quality_care',
|
||||||
|
'name_en': 'Quality of Care & Treatment',
|
||||||
|
'name_ar': 'جودة الرعاية والعلاج',
|
||||||
|
'description_en': 'This is for concerns about the actual medical help received.',
|
||||||
|
'description_ar': 'هذا للمخاوف المتعلقة بالمساعدة الطبية الفعلية المتلقاة.',
|
||||||
|
'order': 1,
|
||||||
|
'subcategories': [
|
||||||
|
{
|
||||||
|
'code': 'diagnosis',
|
||||||
|
'name_en': 'Diagnosis concerns',
|
||||||
|
'name_ar': 'مخاوف التشخيص',
|
||||||
|
'description_en': 'Feeling that a condition was missed or incorrectly identified.',
|
||||||
|
'description_ar': 'الشعور بأن الحالة تم تفويتها أو تحديدها بشكل غير صحيح.',
|
||||||
|
'order': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'treatment_effectiveness',
|
||||||
|
'name_en': 'Treatment effectiveness',
|
||||||
|
'name_ar': 'فعالية العلاج',
|
||||||
|
'description_en': 'Feeling that the treatment didn\'t work or made things worse.',
|
||||||
|
'description_ar': 'الشعور بأن العلاج لم يعمل أو جعل الأمور أسوأ.',
|
||||||
|
'order': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'safety_errors',
|
||||||
|
'name_en': 'Safety & Errors',
|
||||||
|
'name_ar': 'السلامة والأخطاء',
|
||||||
|
'description_en': 'Concerns about medication mistakes, falls, or surgical complications.',
|
||||||
|
'description_ar': 'مخاوف بشأن أخطاء الأدوية، أو السقوط، أو مضاعفات الجراحة.',
|
||||||
|
'order': 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'pain_management',
|
||||||
|
'name_en': 'Pain management',
|
||||||
|
'name_ar': 'إدارة الألم',
|
||||||
|
'description_en': 'Feeling that pain was not taken seriously or handled well.',
|
||||||
|
'description_ar': 'الشعور بأن الألم لم يؤخذ بجدية أو تم التعامل معه بشكل جيد.',
|
||||||
|
'order': 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'communication',
|
||||||
|
'name_en': 'Communication & Information',
|
||||||
|
'name_ar': 'التواصل والمعلومات',
|
||||||
|
'description_en': 'This covers how staff spoke to the patient and the clarity of information provided.',
|
||||||
|
'description_ar': 'يغطي هذا كيف تحدث الموظفون مع المريض ووضوح المعلومات المقدمة.',
|
||||||
|
'order': 2,
|
||||||
|
'subcategories': [
|
||||||
|
{
|
||||||
|
'code': 'staff_attitude',
|
||||||
|
'name_en': 'Staff attitude',
|
||||||
|
'name_ar': 'سلوك الموظفين',
|
||||||
|
'description_en': 'Feeling that staff were rude, dismissive, or lacked empathy.',
|
||||||
|
'description_ar': 'الشعور بأن الموظفين كانوا غير مهذبين، أو متعالين، أو يفتقرون إلى التعاطف.',
|
||||||
|
'order': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'lack_information',
|
||||||
|
'name_en': 'Lack of information',
|
||||||
|
'name_ar': 'نقص المعلومات',
|
||||||
|
'description_en': 'Not being told enough about a procedure, risks, or "what happens next."',
|
||||||
|
'description_ar': 'عدم إخبارهم بما يكفي عن إجراء، أو المخاطر، أو "ماذا يحدث بعد ذلك".',
|
||||||
|
'order': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'language_listening',
|
||||||
|
'name_en': 'Language & Listening',
|
||||||
|
'name_ar': 'اللغة والاستماع',
|
||||||
|
'description_en': 'Difficulty being understood or feeling that doctors didn\'t listen to the patient\'s concerns.',
|
||||||
|
'description_ar': 'صعوبة فهمهم أو الشعور بأن الأطباء لم يستمعوا لاهتمامات المريض.',
|
||||||
|
'order': 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'access_timing',
|
||||||
|
'name_en': 'Access & Timing',
|
||||||
|
'name_ar': 'الوصول والتوقيت',
|
||||||
|
'description_en': 'These are "logistical" complaints regarding the patient\'s schedule and ability to get care.',
|
||||||
|
'description_ar': 'هذه شكاوى "لوجستية" تتعلق بجدول المريض وقدرته على الحصول على الرعاية.',
|
||||||
|
'order': 3,
|
||||||
|
'subcategories': [
|
||||||
|
{
|
||||||
|
'code': 'waiting_times',
|
||||||
|
'name_en': 'Waiting times',
|
||||||
|
'name_ar': 'أوقات الانتظار',
|
||||||
|
'description_en': 'Long delays in the waiting room or waiting too long for a scheduled surgery.',
|
||||||
|
'description_ar': 'تأخير طويل في غرفة الانتظار أو الانتظار لفترة طويلة جداً لعملية جراحية مجدولة.',
|
||||||
|
'order': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'appointment_issues',
|
||||||
|
'name_en': 'Appointment issues',
|
||||||
|
'name_ar': 'مشاكل المواعيد',
|
||||||
|
'description_en': 'Difficulty booking an appointment or having one cancelled at the last minute.',
|
||||||
|
'description_ar': 'صعوبة حجز موعد أو إلغاء موعد في اللحظة الأخيرة.',
|
||||||
|
'order': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'referrals',
|
||||||
|
'name_en': 'Referrals',
|
||||||
|
'name_ar': 'الإحالات',
|
||||||
|
'description_en': 'Problems getting sent to a specialist or another hospital.',
|
||||||
|
'description_ar': 'مشاكل في الإحالة إلى أخصائي أو مستشفى آخر.',
|
||||||
|
'order': 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'facility_environment',
|
||||||
|
'name_en': 'Facility & Environment',
|
||||||
|
'name_ar': 'المرفق والبيئة',
|
||||||
|
'description_en': 'This focuses on the physical space where care was provided.',
|
||||||
|
'description_ar': 'يركز هذا على المساحة المادية حيث تم تقديم الرعاية.',
|
||||||
|
'order': 4,
|
||||||
|
'subcategories': [
|
||||||
|
{
|
||||||
|
'code': 'cleanliness',
|
||||||
|
'name_en': 'Cleanliness',
|
||||||
|
'name_ar': 'النظافة',
|
||||||
|
'description_en': 'Issues with the hygiene of rooms, bathrooms, or equipment.',
|
||||||
|
'description_ar': 'مشاكل في نظافة الغرف، أو الحمامات، أو المعدات.',
|
||||||
|
'order': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'food_catering',
|
||||||
|
'name_en': 'Food & Catering',
|
||||||
|
'name_ar': 'الطعام والتموين',
|
||||||
|
'description_en': 'Poor quality or incorrect meals during a hospital stay.',
|
||||||
|
'description_ar': 'جودة رديئة أو وجبات غير صحيحة خلال إقامة المستشفى.',
|
||||||
|
'order': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'environment',
|
||||||
|
'name_en': 'Environment',
|
||||||
|
'name_ar': 'البيئة',
|
||||||
|
'description_en': 'Issues with noise levels, room temperature, or parking.',
|
||||||
|
'description_ar': 'مشاكل مع مستويات الضوضاء، أو درجة حرارة الغرفة، أو مواقف السيارات.',
|
||||||
|
'order': 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'rights_privacy_billing',
|
||||||
|
'name_en': 'Rights, Privacy & Billing',
|
||||||
|
'name_ar': 'الحقوق والخصوصية والفواتير',
|
||||||
|
'description_en': 'These involve the administrative and ethical side of the healthcare experience.',
|
||||||
|
'description_ar': 'هذه تتضمن الجانب الإداري والأخلاقي لتجربة الرعاية الصحية.',
|
||||||
|
'order': 5,
|
||||||
|
'subcategories': [
|
||||||
|
{
|
||||||
|
'code': 'privacy_confidentiality',
|
||||||
|
'name_en': 'Privacy & Confidentiality',
|
||||||
|
'name_ar': 'الخصوصية والسرية',
|
||||||
|
'description_en': 'Feeling that personal medical information was shared inappropriately.',
|
||||||
|
'description_ar': 'الشعور بأن المعلومات الطبية الشخصية تم مشاركتها بشكل غير مناسب.',
|
||||||
|
'order': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'consent',
|
||||||
|
'name_en': 'Consent',
|
||||||
|
'name_ar': 'الموافقة',
|
||||||
|
'description_en': 'Feeling pressured into a decision or not being asked for permission before a treatment.',
|
||||||
|
'description_ar': 'الشعور بالضغط لاتخاذ قرار أو عدم طلب الإذن قبل العلاج.',
|
||||||
|
'order': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'billing_costs',
|
||||||
|
'name_en': 'Billing & Costs',
|
||||||
|
'name_ar': 'الفواتير والتكاليف',
|
||||||
|
'description_en': 'Confusion or disagreement over charges and insurance.',
|
||||||
|
'description_ar': 'الارتباك أو الخلاف حول الرسوم والتأمين.',
|
||||||
|
'order': 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create categories
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for category_data in categories_data:
|
||||||
|
subcategories_data = category_data.pop('subcategories', None)
|
||||||
|
|
||||||
|
# Get or create category (system-wide - no hospitals assigned)
|
||||||
|
category, created = ComplaintCategory.objects.update_or_create(
|
||||||
|
code=category_data['code'],
|
||||||
|
defaults=category_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'Created category: {category.name_en}')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'Updated category: {category.name_en}')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create subcategories
|
||||||
|
if subcategories_data:
|
||||||
|
for subcat_data in subcategories_data:
|
||||||
|
subcat, sub_created = ComplaintCategory.objects.update_or_create(
|
||||||
|
code=subcat_data['code'],
|
||||||
|
parent=category,
|
||||||
|
defaults=subcat_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if sub_created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f' Created subcategory: {subcat.name_en}')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f' Updated subcategory: {subcat.name_en}')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'\nDone! Created {created_count} items, updated {updated_count} items.'
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:56
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
@ -12,21 +12,147 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('organizations', '0001_initial'),
|
('organizations', '0001_initial'),
|
||||||
('surveys', '0002_surveyquestion_surveyresponse_and_more'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
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': ['hospital', 'order', 'name_en'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ComplaintSLAConfig',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Severity level for this SLA', max_length=20)),
|
||||||
|
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Priority level for this SLA', max_length=20)),
|
||||||
|
('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')),
|
||||||
|
('reminder_hours_before', models.IntegerField(default=24, help_text='Send reminder X hours before deadline')),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['hospital', 'severity', 'priority'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ComplaintThreshold',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('threshold_type', models.CharField(choices=[('resolution_survey_score', 'Resolution Survey Score'), ('response_time', 'Response Time'), ('resolution_time', 'Resolution Time')], help_text='Type of threshold', max_length=50)),
|
||||||
|
('threshold_value', models.FloatField(help_text='Threshold value (e.g., 50 for 50% score)')),
|
||||||
|
('comparison_operator', models.CharField(choices=[('lt', 'Less Than'), ('lte', 'Less Than or Equal'), ('gt', 'Greater Than'), ('gte', 'Greater Than or Equal'), ('eq', 'Equal')], default='lt', help_text='How to compare against threshold', max_length=10)),
|
||||||
|
('action_type', models.CharField(choices=[('create_px_action', 'Create PX Action'), ('send_notification', 'Send Notification'), ('escalate', 'Escalate')], help_text='Action to take when threshold is breached', max_length=50)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['hospital', 'threshold_type'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ComplaintUpdate',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('update_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Note'), ('resolution', 'Resolution'), ('escalation', 'Escalation'), ('communication', 'Communication')], db_index=True, max_length=50)),
|
||||||
|
('message', models.TextField()),
|
||||||
|
('old_status', models.CharField(blank=True, max_length=20)),
|
||||||
|
('new_status', models.CharField(blank=True, max_length=20)),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EscalationRule',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('trigger_on_overdue', models.BooleanField(default=True, help_text='Trigger when complaint is overdue')),
|
||||||
|
('trigger_hours_overdue', models.IntegerField(default=0, help_text='Trigger X hours after overdue (0 = immediately)')),
|
||||||
|
('escalate_to_role', models.CharField(choices=[('department_manager', 'Department Manager'), ('hospital_admin', 'Hospital Admin'), ('px_admin', 'PX Admin'), ('specific_user', 'Specific User')], help_text='Role to escalate to', max_length=50)),
|
||||||
|
('severity_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this severity (blank = all)', max_length=20)),
|
||||||
|
('priority_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this priority (blank = all)', max_length=20)),
|
||||||
|
('order', models.IntegerField(default=0, help_text='Escalation order (lower = first)')),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['hospital', 'order'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='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(
|
migrations.CreateModel(
|
||||||
name='Complaint',
|
name='Complaint',
|
||||||
fields=[
|
fields=[
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('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)),
|
('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)),
|
('title', models.CharField(max_length=500)),
|
||||||
('description', models.TextField()),
|
('description', models.TextField()),
|
||||||
('category', models.CharField(choices=[('clinical_care', 'Clinical Care'), ('staff_behavior', 'Staff Behavior'), ('facility', 'Facility & Environment'), ('wait_time', 'Wait Time'), ('billing', 'Billing'), ('communication', 'Communication'), ('other', 'Other')], db_index=True, max_length=100)),
|
|
||||||
('subcategory', models.CharField(blank=True, max_length=100)),
|
('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)),
|
('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)),
|
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
|
||||||
@ -46,103 +172,10 @@ class Migration(migrations.Migration):
|
|||||||
('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)),
|
('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')),
|
('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')),
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.hospital')),
|
||||||
('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.patient')),
|
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.patient')),
|
||||||
('physician', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.physician')),
|
|
||||||
('resolution_survey', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_resolution', to='surveys.surveyinstance')),
|
|
||||||
('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_complaints', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['-created_at'],
|
'ordering': ['-created_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
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)),
|
|
||||||
('complaint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaint')),
|
|
||||||
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_attachments', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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)),
|
|
||||||
('complaint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='complaints.complaint')),
|
|
||||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_updates', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Inquiry',
|
|
||||||
fields=[
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('contact_name', models.CharField(blank=True, max_length=200)),
|
|
||||||
('contact_phone', models.CharField(blank=True, max_length=20)),
|
|
||||||
('contact_email', models.EmailField(blank=True, max_length=254)),
|
|
||||||
('subject', models.CharField(max_length=500)),
|
|
||||||
('message', models.TextField()),
|
|
||||||
('category', models.CharField(choices=[('appointment', 'Appointment'), ('billing', 'Billing'), ('medical_records', 'Medical Records'), ('general', 'General Information'), ('other', 'Other')], max_length=100)),
|
|
||||||
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='open', max_length=20)),
|
|
||||||
('response', models.TextField(blank=True)),
|
|
||||||
('responded_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_inquiries', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.department')),
|
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.hospital')),
|
|
||||||
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.patient')),
|
|
||||||
('responded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responded_inquiries', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name_plural': 'Inquiries',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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='complaintupdate',
|
|
||||||
index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_f3684e_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'),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-25 13:56
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('complaints', '0001_initial'),
|
|
||||||
('organizations', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ComplaintCategory',
|
|
||||||
fields=[
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('code', models.CharField(help_text='Unique code for this category', max_length=50)),
|
|
||||||
('name_en', models.CharField(max_length=200)),
|
|
||||||
('name_ar', models.CharField(blank=True, max_length=200)),
|
|
||||||
('description_en', models.TextField(blank=True)),
|
|
||||||
('description_ar', models.TextField(blank=True)),
|
|
||||||
('order', models.IntegerField(default=0, help_text='Display order')),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide categories', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='complaint_categories', to='organizations.hospital')),
|
|
||||||
('parent', models.ForeignKey(blank=True, help_text='Parent category for hierarchical structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='complaints.complaintcategory')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name_plural': 'Complaint Categories',
|
|
||||||
'ordering': ['hospital', 'order', 'name_en'],
|
|
||||||
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_a31674_idx'), models.Index(fields=['code'], name='complaints__code_8e9bbe_idx')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ComplaintSLAConfig',
|
|
||||||
fields=[
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Severity level for this SLA', max_length=20)),
|
|
||||||
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Priority level for this SLA', max_length=20)),
|
|
||||||
('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')),
|
|
||||||
('reminder_hours_before', models.IntegerField(default=24, help_text='Send reminder X hours before deadline')),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_sla_configs', to='organizations.hospital')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['hospital', 'severity', 'priority'],
|
|
||||||
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_bdf8a5_idx')],
|
|
||||||
'unique_together': {('hospital', 'severity', 'priority')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ComplaintThreshold',
|
|
||||||
fields=[
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('threshold_type', models.CharField(choices=[('resolution_survey_score', 'Resolution Survey Score'), ('response_time', 'Response Time'), ('resolution_time', 'Resolution Time')], help_text='Type of threshold', max_length=50)),
|
|
||||||
('threshold_value', models.FloatField(help_text='Threshold value (e.g., 50 for 50% score)')),
|
|
||||||
('comparison_operator', models.CharField(choices=[('lt', 'Less Than'), ('lte', 'Less Than or Equal'), ('gt', 'Greater Than'), ('gte', 'Greater Than or Equal'), ('eq', 'Equal')], default='lt', help_text='How to compare against threshold', max_length=10)),
|
|
||||||
('action_type', models.CharField(choices=[('create_px_action', 'Create PX Action'), ('send_notification', 'Send Notification'), ('escalate', 'Escalate')], help_text='Action to take when threshold is breached', max_length=50)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_thresholds', to='organizations.hospital')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['hospital', 'threshold_type'],
|
|
||||||
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_b8efc9_idx'), models.Index(fields=['threshold_type', 'is_active'], name='complaints__thresho_719969_idx')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='EscalationRule',
|
|
||||||
fields=[
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('name', models.CharField(max_length=200)),
|
|
||||||
('description', models.TextField(blank=True)),
|
|
||||||
('trigger_on_overdue', models.BooleanField(default=True, help_text='Trigger when complaint is overdue')),
|
|
||||||
('trigger_hours_overdue', models.IntegerField(default=0, help_text='Trigger X hours after overdue (0 = immediately)')),
|
|
||||||
('escalate_to_role', models.CharField(choices=[('department_manager', 'Department Manager'), ('hospital_admin', 'Hospital Admin'), ('px_admin', 'PX Admin'), ('specific_user', 'Specific User')], help_text='Role to escalate to', max_length=50)),
|
|
||||||
('severity_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this severity (blank = all)', max_length=20)),
|
|
||||||
('priority_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this priority (blank = all)', max_length=20)),
|
|
||||||
('order', models.IntegerField(default=0, help_text='Escalation order (lower = first)')),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('escalate_to_user', models.ForeignKey(blank=True, help_text="Specific user if escalate_to_role is 'specific_user'", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalation_target_rules', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='escalation_rules', to='organizations.hospital')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['hospital', 'order'],
|
|
||||||
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_3c8bac_idx')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
171
apps/complaints/migrations/0002_initial.py
Normal file
171
apps/complaints/migrations/0002_initial.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
|
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'),
|
||||||
|
('organizations', '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),
|
||||||
|
),
|
||||||
|
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='hospital',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Leave blank for system-wide categories', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='complaint_categories', to='organizations.hospital'),
|
||||||
|
),
|
||||||
|
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='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='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.AddIndex(
|
||||||
|
model_name='complaintcategory',
|
||||||
|
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_a31674_idx'),
|
||||||
|
),
|
||||||
|
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='complaintslaconfig',
|
||||||
|
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_bdf8a5_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='complaintslaconfig',
|
||||||
|
unique_together={('hospital', 'severity', 'priority')},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='complaintthreshold',
|
||||||
|
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_b8efc9_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='complaintthreshold',
|
||||||
|
index=models.Index(fields=['threshold_type', 'is_active'], name='complaints__thresho_719969_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='complaintupdate',
|
||||||
|
index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_f3684e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='escalationrule',
|
||||||
|
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_3c8bac_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='inquiry',
|
||||||
|
index=models.Index(fields=['status', '-created_at'], name='complaints__status_3d0678_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='inquiry',
|
||||||
|
index=models.Index(fields=['hospital', 'status'], name='complaints__hospita_b1573b_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-05 13:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0002_initial'),
|
||||||
|
('organizations', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='complaintcategory',
|
||||||
|
options={'ordering': ['order', 'name_en'], 'verbose_name_plural': 'Complaint Categories'},
|
||||||
|
),
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='complaintcategory',
|
||||||
|
name='complaints__hospita_a31674_idx',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='complaintcategory',
|
||||||
|
name='hospital',
|
||||||
|
),
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -14,7 +14,7 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.core.models import PriorityChoices, SeverityChoices, TimeStampedModel, UUIDModel
|
from apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel
|
||||||
|
|
||||||
|
|
||||||
class ComplaintStatus(models.TextChoices):
|
class ComplaintStatus(models.TextChoices):
|
||||||
@ -39,16 +39,74 @@ class ComplaintSource(models.TextChoices):
|
|||||||
OTHER = 'other', 'Other'
|
OTHER = 'other', 'Other'
|
||||||
|
|
||||||
|
|
||||||
|
class ComplaintCategory(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Custom complaint categories per hospital.
|
||||||
|
|
||||||
|
Replaces hardcoded category choices with flexible, hospital-specific categories.
|
||||||
|
Uses ManyToMany to allow categories to be shared across multiple hospitals.
|
||||||
|
"""
|
||||||
|
hospitals = models.ManyToManyField(
|
||||||
|
'organizations.Hospital',
|
||||||
|
blank=True,
|
||||||
|
related_name='complaint_categories',
|
||||||
|
help_text="Empty list = system-wide category. Add hospitals to share category."
|
||||||
|
)
|
||||||
|
|
||||||
|
code = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
help_text="Unique code for this category"
|
||||||
|
)
|
||||||
|
|
||||||
|
name_en = models.CharField(max_length=200)
|
||||||
|
name_ar = models.CharField(max_length=200, blank=True)
|
||||||
|
|
||||||
|
description_en = models.TextField(blank=True)
|
||||||
|
description_ar = models.TextField(blank=True)
|
||||||
|
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='subcategories',
|
||||||
|
help_text="Parent category for hierarchical structure"
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Display order"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['order', 'name_en']
|
||||||
|
verbose_name_plural = 'Complaint Categories'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['code']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
hospital_count = self.hospitals.count()
|
||||||
|
if hospital_count == 0:
|
||||||
|
return f"System-wide - {self.name_en}"
|
||||||
|
elif hospital_count == 1:
|
||||||
|
return f"{self.hospitals.first().name} - {self.name_en}"
|
||||||
|
else:
|
||||||
|
return f"Multiple hospitals - {self.name_en}"
|
||||||
|
|
||||||
|
|
||||||
class Complaint(UUIDModel, TimeStampedModel):
|
class Complaint(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Complaint model with SLA tracking.
|
Complaint model with SLA tracking.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. OPEN - Complaint received
|
1. OPEN - Complaint received
|
||||||
2. IN_PROGRESS - Being investigated
|
2. IN_PROGRESS - Being investigated
|
||||||
3. RESOLVED - Solution provided
|
3. RESOLVED - Solution provided
|
||||||
4. CLOSED - Confirmed closed (triggers resolution satisfaction survey)
|
4. CLOSED - Confirmed closed (triggers resolution satisfaction survey)
|
||||||
|
|
||||||
SLA:
|
SLA:
|
||||||
- Calculated based on severity and hospital configuration
|
- Calculated based on severity and hospital configuration
|
||||||
- Reminders sent before due date
|
- Reminders sent before due date
|
||||||
@ -57,16 +115,34 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
# Patient and encounter information
|
# Patient and encounter information
|
||||||
patient = models.ForeignKey(
|
patient = models.ForeignKey(
|
||||||
'organizations.Patient',
|
'organizations.Patient',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
related_name='complaints'
|
related_name='complaints'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Contact information for anonymous/unregistered submissions
|
||||||
|
contact_name = models.CharField(max_length=200, blank=True)
|
||||||
|
contact_phone = models.CharField(max_length=20, blank=True)
|
||||||
|
contact_email = models.EmailField(blank=True)
|
||||||
|
|
||||||
|
# Reference number for tracking
|
||||||
|
reference_number = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Unique reference number for patient tracking"
|
||||||
|
)
|
||||||
|
|
||||||
encounter_id = models.CharField(
|
encounter_id = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
blank=True,
|
blank=True,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Related encounter ID if applicable"
|
help_text="Related encounter ID if applicable"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Organization
|
# Organization
|
||||||
hospital = models.ForeignKey(
|
hospital = models.ForeignKey(
|
||||||
'organizations.Hospital',
|
'organizations.Hospital',
|
||||||
@ -80,34 +156,28 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='complaints'
|
related_name='complaints'
|
||||||
)
|
)
|
||||||
physician = models.ForeignKey(
|
staff = models.ForeignKey(
|
||||||
'organizations.Physician',
|
'organizations.Staff',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='complaints'
|
related_name='complaints'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Complaint details
|
# Complaint details
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
description = models.TextField()
|
description = models.TextField()
|
||||||
|
|
||||||
# Classification
|
# Classification
|
||||||
category = models.CharField(
|
category = models.ForeignKey(
|
||||||
max_length=100,
|
ComplaintCategory,
|
||||||
choices=[
|
on_delete=models.PROTECT,
|
||||||
('clinical_care', 'Clinical Care'),
|
related_name='complaints',
|
||||||
('staff_behavior', 'Staff Behavior'),
|
null=True,
|
||||||
('facility', 'Facility & Environment'),
|
blank=True
|
||||||
('wait_time', 'Wait Time'),
|
|
||||||
('billing', 'Billing'),
|
|
||||||
('communication', 'Communication'),
|
|
||||||
('other', 'Other'),
|
|
||||||
],
|
|
||||||
db_index=True
|
|
||||||
)
|
)
|
||||||
subcategory = models.CharField(max_length=100, blank=True)
|
subcategory = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
# Priority and severity
|
# Priority and severity
|
||||||
priority = models.CharField(
|
priority = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -121,7 +191,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
default=SeverityChoices.MEDIUM,
|
default=SeverityChoices.MEDIUM,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Source
|
# Source
|
||||||
source = models.CharField(
|
source = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -129,7 +199,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
default=ComplaintSource.PATIENT,
|
default=ComplaintSource.PATIENT,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Status and workflow
|
# Status and workflow
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -137,7 +207,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
default=ComplaintStatus.OPEN,
|
default=ComplaintStatus.OPEN,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assignment
|
# Assignment
|
||||||
assigned_to = models.ForeignKey(
|
assigned_to = models.ForeignKey(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
@ -147,7 +217,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
related_name='assigned_complaints'
|
related_name='assigned_complaints'
|
||||||
)
|
)
|
||||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
# SLA tracking
|
# SLA tracking
|
||||||
due_at = models.DateTimeField(
|
due_at = models.DateTimeField(
|
||||||
db_index=True,
|
db_index=True,
|
||||||
@ -156,7 +226,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
is_overdue = models.BooleanField(default=False, db_index=True)
|
is_overdue = models.BooleanField(default=False, db_index=True)
|
||||||
reminder_sent_at = models.DateTimeField(null=True, blank=True)
|
reminder_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
escalated_at = models.DateTimeField(null=True, blank=True)
|
escalated_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
# Resolution
|
# Resolution
|
||||||
resolution = models.TextField(blank=True)
|
resolution = models.TextField(blank=True)
|
||||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||||
@ -167,7 +237,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='resolved_complaints'
|
related_name='resolved_complaints'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Closure
|
# Closure
|
||||||
closed_at = models.DateTimeField(null=True, blank=True)
|
closed_at = models.DateTimeField(null=True, blank=True)
|
||||||
closed_by = models.ForeignKey(
|
closed_by = models.ForeignKey(
|
||||||
@ -177,7 +247,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='closed_complaints'
|
related_name='closed_complaints'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolution satisfaction survey
|
# Resolution satisfaction survey
|
||||||
resolution_survey = models.ForeignKey(
|
resolution_survey = models.ForeignKey(
|
||||||
'surveys.SurveyInstance',
|
'surveys.SurveyInstance',
|
||||||
@ -187,10 +257,10 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
related_name='complaint_resolution'
|
related_name='complaint_resolution'
|
||||||
)
|
)
|
||||||
resolution_survey_sent_at = models.DateTimeField(null=True, blank=True)
|
resolution_survey_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -199,20 +269,20 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
models.Index(fields=['is_overdue', 'status']),
|
models.Index(fields=['is_overdue', 'status']),
|
||||||
models.Index(fields=['due_at', 'status']),
|
models.Index(fields=['due_at', 'status']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} - {self.patient.get_full_name()} ({self.status})"
|
return f"{self.title} - ({self.status})"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Calculate SLA due date on creation"""
|
"""Calculate SLA due date on creation"""
|
||||||
if not self.due_at:
|
if not self.due_at:
|
||||||
self.due_at = self.calculate_sla_due_date()
|
self.due_at = self.calculate_sla_due_date()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def calculate_sla_due_date(self):
|
def calculate_sla_due_date(self):
|
||||||
"""
|
"""
|
||||||
Calculate SLA due date based on severity and hospital configuration.
|
Calculate SLA due date based on severity and hospital configuration.
|
||||||
|
|
||||||
First tries to use ComplaintSLAConfig from database.
|
First tries to use ComplaintSLAConfig from database.
|
||||||
Falls back to settings.SLA_DEFAULTS if no config exists.
|
Falls back to settings.SLA_DEFAULTS if no config exists.
|
||||||
"""
|
"""
|
||||||
@ -231,14 +301,14 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
self.severity,
|
self.severity,
|
||||||
settings.SLA_DEFAULTS['complaint']['medium']
|
settings.SLA_DEFAULTS['complaint']['medium']
|
||||||
)
|
)
|
||||||
|
|
||||||
return timezone.now() + timedelta(hours=sla_hours)
|
return timezone.now() + timedelta(hours=sla_hours)
|
||||||
|
|
||||||
def check_overdue(self):
|
def check_overdue(self):
|
||||||
"""Check if complaint is overdue and update status"""
|
"""Check if complaint is overdue and update status"""
|
||||||
if self.status in [ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED]:
|
if self.status in [ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if timezone.now() > self.due_at:
|
if timezone.now() > self.due_at:
|
||||||
if not self.is_overdue:
|
if not self.is_overdue:
|
||||||
self.is_overdue = True
|
self.is_overdue = True
|
||||||
@ -246,6 +316,117 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_description_en(self):
|
||||||
|
"""Get AI-generated short description (English) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('short_description_en', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_description_ar(self):
|
||||||
|
"""Get AI-generated short description (Arabic) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('short_description_ar', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_description(self):
|
||||||
|
"""Get AI-generated short description from metadata (deprecated, use short_description_en)"""
|
||||||
|
return self.short_description_en
|
||||||
|
|
||||||
|
@property
|
||||||
|
def suggested_action_en(self):
|
||||||
|
"""Get AI-generated suggested action (English) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('suggested_action_en', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def suggested_action_ar(self):
|
||||||
|
"""Get AI-generated suggested action (Arabic) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('suggested_action_ar', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def suggested_action(self):
|
||||||
|
"""Get AI-generated suggested action from metadata (deprecated, use suggested_action_en)"""
|
||||||
|
return self.suggested_action_en
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title_en(self):
|
||||||
|
"""Get AI-generated title (English) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('title_en', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title_ar(self):
|
||||||
|
"""Get AI-generated title (Arabic) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('title_ar', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reasoning_en(self):
|
||||||
|
"""Get AI-generated reasoning (English) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('reasoning_en', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reasoning_ar(self):
|
||||||
|
"""Get AI-generated reasoning (Arabic) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('reasoning_ar', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def emotion(self):
|
||||||
|
"""Get AI-detected primary emotion from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('emotion', 'neutral')
|
||||||
|
return 'neutral'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def emotion_intensity(self):
|
||||||
|
"""Get AI-detected emotion intensity (0.0 to 1.0) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('emotion_intensity', 0.0)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def emotion_confidence(self):
|
||||||
|
"""Get AI confidence in emotion detection (0.0 to 1.0) from metadata"""
|
||||||
|
if self.metadata and 'ai_analysis' in self.metadata:
|
||||||
|
return self.metadata['ai_analysis'].get('emotion_confidence', 0.0)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_emotion_display(self):
|
||||||
|
"""Get human-readable emotion display"""
|
||||||
|
emotion_map = {
|
||||||
|
'anger': 'Anger',
|
||||||
|
'sadness': 'Sadness',
|
||||||
|
'confusion': 'Confusion',
|
||||||
|
'fear': 'Fear',
|
||||||
|
'neutral': 'Neutral'
|
||||||
|
}
|
||||||
|
return emotion_map.get(self.emotion, 'Neutral')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_emotion_badge_class(self):
|
||||||
|
"""Get Bootstrap badge class for emotion"""
|
||||||
|
badge_map = {
|
||||||
|
'anger': 'danger',
|
||||||
|
'sadness': 'primary',
|
||||||
|
'confusion': 'warning',
|
||||||
|
'fear': 'info',
|
||||||
|
'neutral': 'secondary'
|
||||||
|
}
|
||||||
|
return badge_map.get(self.emotion, 'secondary')
|
||||||
|
|
||||||
|
|
||||||
class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
||||||
"""Complaint attachment (images, documents, etc.)"""
|
"""Complaint attachment (images, documents, etc.)"""
|
||||||
@ -254,24 +435,24 @@ class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='attachments'
|
related_name='attachments'
|
||||||
)
|
)
|
||||||
|
|
||||||
file = models.FileField(upload_to='complaints/%Y/%m/%d/')
|
file = models.FileField(upload_to='complaints/%Y/%m/%d/')
|
||||||
filename = models.CharField(max_length=500)
|
filename = models.CharField(max_length=500)
|
||||||
file_type = models.CharField(max_length=100, blank=True)
|
file_type = models.CharField(max_length=100, blank=True)
|
||||||
file_size = models.IntegerField(help_text="File size in bytes")
|
file_size = models.IntegerField(help_text="File size in bytes")
|
||||||
|
|
||||||
uploaded_by = models.ForeignKey(
|
uploaded_by = models.ForeignKey(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
related_name='complaint_attachments'
|
related_name='complaint_attachments'
|
||||||
)
|
)
|
||||||
|
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.complaint} - {self.filename}"
|
return f"{self.complaint} - {self.filename}"
|
||||||
|
|
||||||
@ -279,7 +460,7 @@ class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
|||||||
class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Complaint update/timeline entry.
|
Complaint update/timeline entry.
|
||||||
|
|
||||||
Tracks all updates, status changes, and communications.
|
Tracks all updates, status changes, and communications.
|
||||||
"""
|
"""
|
||||||
complaint = models.ForeignKey(
|
complaint = models.ForeignKey(
|
||||||
@ -287,7 +468,7 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='updates'
|
related_name='updates'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update details
|
# Update details
|
||||||
update_type = models.CharField(
|
update_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -301,9 +482,9 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
|||||||
],
|
],
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
message = models.TextField()
|
message = models.TextField()
|
||||||
|
|
||||||
# User who made the update
|
# User who made the update
|
||||||
created_by = models.ForeignKey(
|
created_by = models.ForeignKey(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
@ -311,20 +492,20 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
|||||||
null=True,
|
null=True,
|
||||||
related_name='complaint_updates'
|
related_name='complaint_updates'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Status change tracking
|
# Status change tracking
|
||||||
old_status = models.CharField(max_length=20, blank=True)
|
old_status = models.CharField(max_length=20, blank=True)
|
||||||
new_status = models.CharField(max_length=20, blank=True)
|
new_status = models.CharField(max_length=20, blank=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['complaint', '-created_at']),
|
models.Index(fields=['complaint', '-created_at']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.complaint} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
return f"{self.complaint} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
@ -332,7 +513,7 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
|||||||
class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
|
class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
SLA configuration for complaints per hospital, severity, and priority.
|
SLA configuration for complaints per hospital, severity, and priority.
|
||||||
|
|
||||||
Allows flexible SLA configuration instead of hardcoded values.
|
Allows flexible SLA configuration instead of hardcoded values.
|
||||||
"""
|
"""
|
||||||
hospital = models.ForeignKey(
|
hospital = models.ForeignKey(
|
||||||
@ -340,100 +521,47 @@ class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='complaint_sla_configs'
|
related_name='complaint_sla_configs'
|
||||||
)
|
)
|
||||||
|
|
||||||
severity = models.CharField(
|
severity = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=SeverityChoices.choices,
|
choices=SeverityChoices.choices,
|
||||||
help_text="Severity level for this SLA"
|
help_text="Severity level for this SLA"
|
||||||
)
|
)
|
||||||
|
|
||||||
priority = models.CharField(
|
priority = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=PriorityChoices.choices,
|
choices=PriorityChoices.choices,
|
||||||
help_text="Priority level for this SLA"
|
help_text="Priority level for this SLA"
|
||||||
)
|
)
|
||||||
|
|
||||||
sla_hours = models.IntegerField(
|
sla_hours = models.IntegerField(
|
||||||
help_text="Number of hours until SLA deadline"
|
help_text="Number of hours until SLA deadline"
|
||||||
)
|
)
|
||||||
|
|
||||||
reminder_hours_before = models.IntegerField(
|
reminder_hours_before = models.IntegerField(
|
||||||
default=24,
|
default=24,
|
||||||
help_text="Send reminder X hours before deadline"
|
help_text="Send reminder X hours before deadline"
|
||||||
)
|
)
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['hospital', 'severity', 'priority']
|
ordering = ['hospital', 'severity', 'priority']
|
||||||
unique_together = [['hospital', 'severity', 'priority']]
|
unique_together = [['hospital', 'severity', 'priority']]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['hospital', 'is_active']),
|
models.Index(fields=['hospital', 'is_active']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.hospital.name} - {self.severity}/{self.priority} - {self.sla_hours}h"
|
return f"{self.hospital.name} - {self.severity}/{self.priority} - {self.sla_hours}h"
|
||||||
|
|
||||||
|
|
||||||
class ComplaintCategory(UUIDModel, TimeStampedModel):
|
|
||||||
"""
|
|
||||||
Custom complaint categories per hospital.
|
|
||||||
|
|
||||||
Replaces hardcoded category choices with flexible, hospital-specific categories.
|
|
||||||
"""
|
|
||||||
hospital = models.ForeignKey(
|
|
||||||
'organizations.Hospital',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='complaint_categories',
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="Leave blank for system-wide categories"
|
|
||||||
)
|
|
||||||
|
|
||||||
code = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
help_text="Unique code for this category"
|
|
||||||
)
|
|
||||||
|
|
||||||
name_en = models.CharField(max_length=200)
|
|
||||||
name_ar = models.CharField(max_length=200, blank=True)
|
|
||||||
|
|
||||||
description_en = models.TextField(blank=True)
|
|
||||||
description_ar = models.TextField(blank=True)
|
|
||||||
|
|
||||||
parent = models.ForeignKey(
|
|
||||||
'self',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='subcategories',
|
|
||||||
help_text="Parent category for hierarchical structure"
|
|
||||||
)
|
|
||||||
|
|
||||||
order = models.IntegerField(
|
|
||||||
default=0,
|
|
||||||
help_text="Display order"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['hospital', 'order', 'name_en']
|
|
||||||
verbose_name_plural = 'Complaint Categories'
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['hospital', 'is_active']),
|
|
||||||
models.Index(fields=['code']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
hospital_name = self.hospital.name if self.hospital else "System-wide"
|
|
||||||
return f"{hospital_name} - {self.name_en}"
|
|
||||||
|
|
||||||
|
|
||||||
class EscalationRule(UUIDModel, TimeStampedModel):
|
class EscalationRule(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Configurable escalation rules for complaints.
|
Configurable escalation rules for complaints.
|
||||||
|
|
||||||
Defines who receives escalated complaints based on conditions.
|
Defines who receives escalated complaints based on conditions.
|
||||||
"""
|
"""
|
||||||
hospital = models.ForeignKey(
|
hospital = models.ForeignKey(
|
||||||
@ -441,21 +569,21 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='escalation_rules'
|
related_name='escalation_rules'
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
# Trigger conditions
|
# Trigger conditions
|
||||||
trigger_on_overdue = models.BooleanField(
|
trigger_on_overdue = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
help_text="Trigger when complaint is overdue"
|
help_text="Trigger when complaint is overdue"
|
||||||
)
|
)
|
||||||
|
|
||||||
trigger_hours_overdue = models.IntegerField(
|
trigger_hours_overdue = models.IntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
help_text="Trigger X hours after overdue (0 = immediately)"
|
help_text="Trigger X hours after overdue (0 = immediately)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Escalation target
|
# Escalation target
|
||||||
escalate_to_role = models.CharField(
|
escalate_to_role = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -467,7 +595,7 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
|||||||
],
|
],
|
||||||
help_text="Role to escalate to"
|
help_text="Role to escalate to"
|
||||||
)
|
)
|
||||||
|
|
||||||
escalate_to_user = models.ForeignKey(
|
escalate_to_user = models.ForeignKey(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -476,7 +604,7 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
|||||||
related_name='escalation_target_rules',
|
related_name='escalation_target_rules',
|
||||||
help_text="Specific user if escalate_to_role is 'specific_user'"
|
help_text="Specific user if escalate_to_role is 'specific_user'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Conditions
|
# Conditions
|
||||||
severity_filter = models.CharField(
|
severity_filter = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -484,27 +612,27 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Only escalate complaints with this severity (blank = all)"
|
help_text="Only escalate complaints with this severity (blank = all)"
|
||||||
)
|
)
|
||||||
|
|
||||||
priority_filter = models.CharField(
|
priority_filter = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=PriorityChoices.choices,
|
choices=PriorityChoices.choices,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Only escalate complaints with this priority (blank = all)"
|
help_text="Only escalate complaints with this priority (blank = all)"
|
||||||
)
|
)
|
||||||
|
|
||||||
order = models.IntegerField(
|
order = models.IntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
help_text="Escalation order (lower = first)"
|
help_text="Escalation order (lower = first)"
|
||||||
)
|
)
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['hospital', 'order']
|
ordering = ['hospital', 'order']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['hospital', 'is_active']),
|
models.Index(fields=['hospital', 'is_active']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.hospital.name} - {self.name}"
|
return f"{self.hospital.name} - {self.name}"
|
||||||
|
|
||||||
@ -512,7 +640,7 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
|||||||
class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Configurable thresholds for complaint-related triggers.
|
Configurable thresholds for complaint-related triggers.
|
||||||
|
|
||||||
Defines when to trigger actions based on metrics (e.g., survey scores).
|
Defines when to trigger actions based on metrics (e.g., survey scores).
|
||||||
"""
|
"""
|
||||||
hospital = models.ForeignKey(
|
hospital = models.ForeignKey(
|
||||||
@ -520,7 +648,7 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='complaint_thresholds'
|
related_name='complaint_thresholds'
|
||||||
)
|
)
|
||||||
|
|
||||||
threshold_type = models.CharField(
|
threshold_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=[
|
||||||
@ -530,11 +658,11 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
|||||||
],
|
],
|
||||||
help_text="Type of threshold"
|
help_text="Type of threshold"
|
||||||
)
|
)
|
||||||
|
|
||||||
threshold_value = models.FloatField(
|
threshold_value = models.FloatField(
|
||||||
help_text="Threshold value (e.g., 50 for 50% score)"
|
help_text="Threshold value (e.g., 50 for 50% score)"
|
||||||
)
|
)
|
||||||
|
|
||||||
comparison_operator = models.CharField(
|
comparison_operator = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
choices=[
|
choices=[
|
||||||
@ -547,7 +675,7 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
|||||||
default='lt',
|
default='lt',
|
||||||
help_text="How to compare against threshold"
|
help_text="How to compare against threshold"
|
||||||
)
|
)
|
||||||
|
|
||||||
action_type = models.CharField(
|
action_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=[
|
||||||
@ -557,19 +685,19 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
|||||||
],
|
],
|
||||||
help_text="Action to take when threshold is breached"
|
help_text="Action to take when threshold is breached"
|
||||||
)
|
)
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['hospital', 'threshold_type']
|
ordering = ['hospital', 'threshold_type']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['hospital', 'is_active']),
|
models.Index(fields=['hospital', 'is_active']),
|
||||||
models.Index(fields=['threshold_type', 'is_active']),
|
models.Index(fields=['threshold_type', 'is_active']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.hospital.name} - {self.threshold_type} {self.comparison_operator} {self.threshold_value}"
|
return f"{self.hospital.name} - {self.threshold_type} {self.comparison_operator} {self.threshold_value}"
|
||||||
|
|
||||||
def check_threshold(self, value):
|
def check_threshold(self, value):
|
||||||
"""Check if value breaches threshold"""
|
"""Check if value breaches threshold"""
|
||||||
if self.comparison_operator == 'lt':
|
if self.comparison_operator == 'lt':
|
||||||
@ -588,7 +716,7 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
|||||||
class Inquiry(UUIDModel, TimeStampedModel):
|
class Inquiry(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Inquiry model for general questions/requests.
|
Inquiry model for general questions/requests.
|
||||||
|
|
||||||
Similar to complaints but for non-complaint inquiries.
|
Similar to complaints but for non-complaint inquiries.
|
||||||
"""
|
"""
|
||||||
# Patient information
|
# Patient information
|
||||||
@ -599,12 +727,12 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='inquiries'
|
related_name='inquiries'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Contact information (if patient not in system)
|
# Contact information (if patient not in system)
|
||||||
contact_name = models.CharField(max_length=200, blank=True)
|
contact_name = models.CharField(max_length=200, blank=True)
|
||||||
contact_phone = models.CharField(max_length=20, blank=True)
|
contact_phone = models.CharField(max_length=20, blank=True)
|
||||||
contact_email = models.EmailField(blank=True)
|
contact_email = models.EmailField(blank=True)
|
||||||
|
|
||||||
# Organization
|
# Organization
|
||||||
hospital = models.ForeignKey(
|
hospital = models.ForeignKey(
|
||||||
'organizations.Hospital',
|
'organizations.Hospital',
|
||||||
@ -618,11 +746,11 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='inquiries'
|
related_name='inquiries'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inquiry details
|
# Inquiry details
|
||||||
subject = models.CharField(max_length=500)
|
subject = models.CharField(max_length=500)
|
||||||
message = models.TextField()
|
message = models.TextField()
|
||||||
|
|
||||||
# Category
|
# Category
|
||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
@ -634,7 +762,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
|||||||
('other', 'Other'),
|
('other', 'Other'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -647,7 +775,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
|||||||
default='open',
|
default='open',
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assignment
|
# Assignment
|
||||||
assigned_to = models.ForeignKey(
|
assigned_to = models.ForeignKey(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
@ -656,7 +784,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='assigned_inquiries'
|
related_name='assigned_inquiries'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Response
|
# Response
|
||||||
response = models.TextField(blank=True)
|
response = models.TextField(blank=True)
|
||||||
responded_at = models.DateTimeField(null=True, blank=True)
|
responded_at = models.DateTimeField(null=True, blank=True)
|
||||||
@ -667,7 +795,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='responded_inquiries'
|
related_name='responded_inquiries'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
verbose_name_plural = 'Inquiries'
|
verbose_name_plural = 'Inquiries'
|
||||||
@ -675,7 +803,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
|||||||
models.Index(fields=['status', '-created_at']),
|
models.Index(fields=['status', '-created_at']),
|
||||||
models.Index(fields=['hospital', 'status']),
|
models.Index(fields=['hospital', 'status']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.subject} ({self.status})"
|
return f"{self.subject} ({self.status})"
|
||||||
|
|
||||||
|
|||||||
@ -18,27 +18,32 @@ logger = logging.getLogger(__name__)
|
|||||||
def handle_complaint_created(sender, instance, created, **kwargs):
|
def handle_complaint_created(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
Handle complaint creation.
|
Handle complaint creation.
|
||||||
|
|
||||||
Triggers:
|
Triggers:
|
||||||
|
- AI-powered severity and priority analysis
|
||||||
- Create PX Action if hospital config requires it
|
- Create PX Action if hospital config requires it
|
||||||
- Send notification to assigned user/department
|
- Send notification to assigned user/department
|
||||||
"""
|
"""
|
||||||
if created:
|
if created:
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from apps.complaints.tasks import (
|
from apps.complaints.tasks import (
|
||||||
|
analyze_complaint_with_ai,
|
||||||
create_action_from_complaint,
|
create_action_from_complaint,
|
||||||
send_complaint_notification,
|
send_complaint_notification,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Trigger AI analysis (determines severity and priority)
|
||||||
|
analyze_complaint_with_ai.delay(str(instance.id))
|
||||||
|
|
||||||
# Trigger PX Action creation (if configured)
|
# Trigger PX Action creation (if configured)
|
||||||
create_action_from_complaint.delay(str(instance.id))
|
create_action_from_complaint.delay(str(instance.id))
|
||||||
|
|
||||||
# Send notification
|
# Send notification
|
||||||
send_complaint_notification.delay(
|
send_complaint_notification.delay(
|
||||||
complaint_id=str(instance.id),
|
complaint_id=str(instance.id),
|
||||||
event_type='created'
|
event_type='created'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Complaint created: {instance.id} - {instance.title}")
|
logger.info(f"Complaint created: {instance.id} - {instance.title}")
|
||||||
|
|
||||||
|
|
||||||
@ -46,7 +51,7 @@ def handle_complaint_created(sender, instance, created, **kwargs):
|
|||||||
def handle_survey_completed(sender, instance, created, **kwargs):
|
def handle_survey_completed(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
Handle survey completion.
|
Handle survey completion.
|
||||||
|
|
||||||
Checks if this is a complaint resolution survey and if score is below threshold.
|
Checks if this is a complaint resolution survey and if score is below threshold.
|
||||||
If so, creates a PX Action automatically.
|
If so, creates a PX Action automatically.
|
||||||
"""
|
"""
|
||||||
@ -54,12 +59,12 @@ def handle_survey_completed(sender, instance, created, **kwargs):
|
|||||||
# Check if this is a complaint resolution survey
|
# Check if this is a complaint resolution survey
|
||||||
if instance.metadata.get('complaint_id'):
|
if instance.metadata.get('complaint_id'):
|
||||||
from apps.complaints.tasks import check_resolution_survey_threshold
|
from apps.complaints.tasks import check_resolution_survey_threshold
|
||||||
|
|
||||||
check_resolution_survey_threshold.delay(
|
check_resolution_survey_threshold.delay(
|
||||||
survey_instance_id=str(instance.id),
|
survey_instance_id=str(instance.id),
|
||||||
complaint_id=instance.metadata['complaint_id']
|
complaint_id=instance.metadata['complaint_id']
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Resolution survey completed for complaint {instance.metadata['complaint_id']}: "
|
f"Resolution survey completed for complaint {instance.metadata['complaint_id']}: "
|
||||||
f"Score = {instance.total_score}"
|
f"Score = {instance.total_score}"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ This module contains tasks for:
|
|||||||
- Sending SLA reminders
|
- Sending SLA reminders
|
||||||
- Triggering resolution satisfaction surveys
|
- Triggering resolution satisfaction surveys
|
||||||
- Creating PX actions from complaints
|
- Creating PX actions from complaints
|
||||||
|
- AI-powered complaint analysis
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -21,21 +22,21 @@ logger = logging.getLogger(__name__)
|
|||||||
def check_overdue_complaints():
|
def check_overdue_complaints():
|
||||||
"""
|
"""
|
||||||
Periodic task to check for overdue complaints.
|
Periodic task to check for overdue complaints.
|
||||||
|
|
||||||
Runs every 15 minutes (configured in config/celery.py).
|
Runs every 15 minutes (configured in config/celery.py).
|
||||||
Updates is_overdue flag for complaints past their SLA deadline.
|
Updates is_overdue flag for complaints past their SLA deadline.
|
||||||
Triggers automatic escalation based on escalation rules.
|
Triggers automatic escalation based on escalation rules.
|
||||||
"""
|
"""
|
||||||
from apps.complaints.models import Complaint, ComplaintStatus
|
from apps.complaints.models import Complaint, ComplaintStatus
|
||||||
|
|
||||||
# Get active complaints (not closed or cancelled)
|
# Get active complaints (not closed or cancelled)
|
||||||
active_complaints = Complaint.objects.filter(
|
active_complaints = Complaint.objects.filter(
|
||||||
status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED]
|
status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED]
|
||||||
).select_related('hospital', 'patient', 'department')
|
).select_related('hospital', 'patient', 'department')
|
||||||
|
|
||||||
overdue_count = 0
|
overdue_count = 0
|
||||||
escalated_count = 0
|
escalated_count = 0
|
||||||
|
|
||||||
for complaint in active_complaints:
|
for complaint in active_complaints:
|
||||||
if complaint.check_overdue():
|
if complaint.check_overdue():
|
||||||
overdue_count += 1
|
overdue_count += 1
|
||||||
@ -43,15 +44,15 @@ def check_overdue_complaints():
|
|||||||
f"Complaint {complaint.id} is overdue: {complaint.title} "
|
f"Complaint {complaint.id} is overdue: {complaint.title} "
|
||||||
f"(due: {complaint.due_at})"
|
f"(due: {complaint.due_at})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trigger automatic escalation
|
# Trigger automatic escalation
|
||||||
result = escalate_complaint_auto.delay(str(complaint.id))
|
result = escalate_complaint_auto.delay(str(complaint.id))
|
||||||
if result:
|
if result:
|
||||||
escalated_count += 1
|
escalated_count += 1
|
||||||
|
|
||||||
if overdue_count > 0:
|
if overdue_count > 0:
|
||||||
logger.info(f"Found {overdue_count} overdue complaints, triggered {escalated_count} escalations")
|
logger.info(f"Found {overdue_count} overdue complaints, triggered {escalated_count} escalations")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'overdue_count': overdue_count,
|
'overdue_count': overdue_count,
|
||||||
'escalated_count': escalated_count
|
'escalated_count': escalated_count
|
||||||
@ -62,29 +63,29 @@ def check_overdue_complaints():
|
|||||||
def send_complaint_resolution_survey(complaint_id):
|
def send_complaint_resolution_survey(complaint_id):
|
||||||
"""
|
"""
|
||||||
Send resolution satisfaction survey when complaint is closed.
|
Send resolution satisfaction survey when complaint is closed.
|
||||||
|
|
||||||
This task is triggered when a complaint status changes to CLOSED.
|
This task is triggered when a complaint status changes to CLOSED.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
complaint_id: UUID of the Complaint
|
complaint_id: UUID of the Complaint
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with survey_instance_id
|
dict: Result with survey_instance_id
|
||||||
"""
|
"""
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
from apps.core.services import create_audit_log
|
from apps.core.services import create_audit_log
|
||||||
from apps.surveys.models import SurveyInstance, SurveyTemplate
|
from apps.surveys.models import SurveyInstance, SurveyTemplate
|
||||||
|
|
||||||
try:
|
try:
|
||||||
complaint = Complaint.objects.select_related(
|
complaint = Complaint.objects.select_related(
|
||||||
'patient', 'hospital'
|
'patient', 'hospital'
|
||||||
).get(id=complaint_id)
|
).get(id=complaint_id)
|
||||||
|
|
||||||
# Check if survey already sent
|
# Check if survey already sent
|
||||||
if complaint.resolution_survey:
|
if complaint.resolution_survey:
|
||||||
logger.info(f"Resolution survey already sent for complaint {complaint_id}")
|
logger.info(f"Resolution survey already sent for complaint {complaint_id}")
|
||||||
return {'status': 'skipped', 'reason': 'already_sent'}
|
return {'status': 'skipped', 'reason': 'already_sent'}
|
||||||
|
|
||||||
# Get resolution satisfaction survey template
|
# Get resolution satisfaction survey template
|
||||||
try:
|
try:
|
||||||
survey_template = SurveyTemplate.objects.get(
|
survey_template = SurveyTemplate.objects.get(
|
||||||
@ -97,7 +98,7 @@ def send_complaint_resolution_survey(complaint_id):
|
|||||||
f"No resolution satisfaction survey template found for hospital {complaint.hospital.name}"
|
f"No resolution satisfaction survey template found for hospital {complaint.hospital.name}"
|
||||||
)
|
)
|
||||||
return {'status': 'skipped', 'reason': 'no_template'}
|
return {'status': 'skipped', 'reason': 'no_template'}
|
||||||
|
|
||||||
# Create survey instance
|
# Create survey instance
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
survey_instance = SurveyInstance.objects.create(
|
survey_instance = SurveyInstance.objects.create(
|
||||||
@ -112,24 +113,24 @@ def send_complaint_resolution_survey(complaint_id):
|
|||||||
'complaint_title': complaint.title
|
'complaint_title': complaint.title
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Link survey to complaint
|
# Link survey to complaint
|
||||||
complaint.resolution_survey = survey_instance
|
complaint.resolution_survey = survey_instance
|
||||||
complaint.resolution_survey_sent_at = timezone.now()
|
complaint.resolution_survey_sent_at = timezone.now()
|
||||||
complaint.save(update_fields=['resolution_survey', 'resolution_survey_sent_at'])
|
complaint.save(update_fields=['resolution_survey', 'resolution_survey_sent_at'])
|
||||||
|
|
||||||
# Send survey
|
# Send survey
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
notification_log = NotificationService.send_survey_invitation(
|
notification_log = NotificationService.send_survey_invitation(
|
||||||
survey_instance=survey_instance,
|
survey_instance=survey_instance,
|
||||||
language='en' # TODO: Get from patient preference
|
language='en' # TODO: Get from patient preference
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update survey status
|
# Update survey status
|
||||||
survey_instance.status = 'active'
|
survey_instance.status = 'active'
|
||||||
survey_instance.sent_at = timezone.now()
|
survey_instance.sent_at = timezone.now()
|
||||||
survey_instance.save(update_fields=['status', 'sent_at'])
|
survey_instance.save(update_fields=['status', 'sent_at'])
|
||||||
|
|
||||||
# Log audit event
|
# Log audit event
|
||||||
create_audit_log(
|
create_audit_log(
|
||||||
event_type='survey_sent',
|
event_type='survey_sent',
|
||||||
@ -140,22 +141,22 @@ def send_complaint_resolution_survey(complaint_id):
|
|||||||
'survey_template': survey_template.name
|
'survey_template': survey_template.name
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Resolution satisfaction survey sent for complaint {complaint.id}"
|
f"Resolution satisfaction survey sent for complaint {complaint.id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'sent',
|
'status': 'sent',
|
||||||
'survey_instance_id': str(survey_instance.id),
|
'survey_instance_id': str(survey_instance.id),
|
||||||
'notification_log_id': str(notification_log.id)
|
'notification_log_id': str(notification_log.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
except Complaint.DoesNotExist:
|
except Complaint.DoesNotExist:
|
||||||
error_msg = f"Complaint {complaint_id} not found"
|
error_msg = f"Complaint {complaint_id} not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {'status': 'error', 'reason': error_msg}
|
return {'status': 'error', 'reason': error_msg}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error sending resolution survey: {str(e)}"
|
error_msg = f"Error sending resolution survey: {str(e)}"
|
||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
@ -166,13 +167,13 @@ def send_complaint_resolution_survey(complaint_id):
|
|||||||
def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
||||||
"""
|
"""
|
||||||
Check if resolution survey score breaches threshold and create PX Action if needed.
|
Check if resolution survey score breaches threshold and create PX Action if needed.
|
||||||
|
|
||||||
This task is triggered when a complaint resolution survey is completed.
|
This task is triggered when a complaint resolution survey is completed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
survey_instance_id: UUID of the SurveyInstance
|
survey_instance_id: UUID of the SurveyInstance
|
||||||
complaint_id: UUID of the Complaint
|
complaint_id: UUID of the Complaint
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with action status
|
dict: Result with action status
|
||||||
"""
|
"""
|
||||||
@ -180,11 +181,11 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
|||||||
from apps.surveys.models import SurveyInstance
|
from apps.surveys.models import SurveyInstance
|
||||||
from apps.px_action_center.models import PXAction
|
from apps.px_action_center.models import PXAction
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
try:
|
try:
|
||||||
survey = SurveyInstance.objects.get(id=survey_instance_id)
|
survey = SurveyInstance.objects.get(id=survey_instance_id)
|
||||||
complaint = Complaint.objects.select_related('hospital', 'patient').get(id=complaint_id)
|
complaint = Complaint.objects.select_related('hospital', 'patient').get(id=complaint_id)
|
||||||
|
|
||||||
# Get threshold for this hospital
|
# Get threshold for this hospital
|
||||||
try:
|
try:
|
||||||
threshold = ComplaintThreshold.objects.get(
|
threshold = ComplaintThreshold.objects.get(
|
||||||
@ -195,17 +196,17 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
|||||||
except ComplaintThreshold.DoesNotExist:
|
except ComplaintThreshold.DoesNotExist:
|
||||||
logger.info(f"No resolution survey threshold configured for hospital {complaint.hospital.name_en}")
|
logger.info(f"No resolution survey threshold configured for hospital {complaint.hospital.name_en}")
|
||||||
return {'status': 'no_threshold'}
|
return {'status': 'no_threshold'}
|
||||||
|
|
||||||
# Check if threshold is breached
|
# Check if threshold is breached
|
||||||
if threshold.check_threshold(survey.score):
|
if threshold.check_threshold(survey.score):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Resolution survey score {survey.score} breaches threshold {threshold.threshold_value} "
|
f"Resolution survey score {survey.score} breaches threshold {threshold.threshold_value} "
|
||||||
f"for complaint {complaint_id}"
|
f"for complaint {complaint_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create PX Action
|
# Create PX Action
|
||||||
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
||||||
|
|
||||||
action = PXAction.objects.create(
|
action = PXAction.objects.create(
|
||||||
title=f"Low Resolution Satisfaction: {complaint.title[:100]}",
|
title=f"Low Resolution Satisfaction: {complaint.title[:100]}",
|
||||||
description=(
|
description=(
|
||||||
@ -227,7 +228,7 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
|||||||
'threshold_value': threshold.threshold_value,
|
'threshold_value': threshold.threshold_value,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
from apps.core.services import create_audit_log
|
from apps.core.services import create_audit_log
|
||||||
create_audit_log(
|
create_audit_log(
|
||||||
@ -240,9 +241,9 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
|||||||
'trigger': 'resolution_survey_threshold'
|
'trigger': 'resolution_survey_threshold'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Created PX Action {action.id} from low resolution survey score")
|
logger.info(f"Created PX Action {action.id} from low resolution survey score")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'action_created',
|
'status': 'action_created',
|
||||||
'action_id': str(action.id),
|
'action_id': str(action.id),
|
||||||
@ -252,7 +253,7 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"Resolution survey score {survey.score} is above threshold {threshold.threshold_value}")
|
logger.info(f"Resolution survey score {survey.score} is above threshold {threshold.threshold_value}")
|
||||||
return {'status': 'threshold_not_breached', 'survey_score': survey.score}
|
return {'status': 'threshold_not_breached', 'survey_score': survey.score}
|
||||||
|
|
||||||
except SurveyInstance.DoesNotExist:
|
except SurveyInstance.DoesNotExist:
|
||||||
error_msg = f"SurveyInstance {survey_instance_id} not found"
|
error_msg = f"SurveyInstance {survey_instance_id} not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
@ -271,13 +272,13 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
|||||||
def create_action_from_complaint(complaint_id):
|
def create_action_from_complaint(complaint_id):
|
||||||
"""
|
"""
|
||||||
Create PX Action from complaint (if configured).
|
Create PX Action from complaint (if configured).
|
||||||
|
|
||||||
This task is triggered when a complaint is created,
|
This task is triggered when a complaint is created,
|
||||||
if the hospital configuration requires automatic action creation.
|
if the hospital configuration requires automatic action creation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
complaint_id: UUID of the Complaint
|
complaint_id: UUID of the Complaint
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with action_id
|
dict: Result with action_id
|
||||||
"""
|
"""
|
||||||
@ -285,22 +286,30 @@ def create_action_from_complaint(complaint_id):
|
|||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Hospital
|
||||||
from apps.px_action_center.models import PXAction
|
from apps.px_action_center.models import PXAction
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
try:
|
try:
|
||||||
complaint = Complaint.objects.select_related('hospital', 'patient', 'department').get(id=complaint_id)
|
complaint = Complaint.objects.select_related('hospital', 'patient', 'department').get(id=complaint_id)
|
||||||
|
|
||||||
# Check if hospital has auto-create enabled
|
# Check if hospital has auto-create enabled
|
||||||
# For now, we'll check metadata on hospital or use a simple rule
|
# For now, we'll check metadata on hospital or use a simple rule
|
||||||
# In production, you'd have a HospitalComplaintConfig model
|
# In production, you'd have a HospitalComplaintConfig model
|
||||||
auto_create = complaint.hospital.metadata.get('auto_create_action_on_complaint', False)
|
# Handle case where metadata field might not exist (legacy data)
|
||||||
|
hospital_metadata = getattr(complaint.hospital, 'metadata', None)
|
||||||
|
if hospital_metadata is None:
|
||||||
|
hospital_metadata = {}
|
||||||
|
auto_create = hospital_metadata.get('auto_create_action_on_complaint', False)
|
||||||
|
|
||||||
if not auto_create:
|
if not auto_create:
|
||||||
logger.info(f"Auto-create PX Action disabled for hospital {complaint.hospital.name_en}")
|
logger.info(f"Auto-create PX Action disabled for hospital {complaint.hospital.name}")
|
||||||
return {'status': 'disabled'}
|
return {'status': 'disabled'}
|
||||||
|
|
||||||
|
# Use JSON-serializable values instead of model objects
|
||||||
|
category_name = complaint.category.name_en if complaint.category else None
|
||||||
|
category_id = str(complaint.category.id) if complaint.category else None
|
||||||
|
|
||||||
# Create PX Action
|
# Create PX Action
|
||||||
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
||||||
|
|
||||||
action = PXAction.objects.create(
|
action = PXAction.objects.create(
|
||||||
title=f"New Complaint: {complaint.title[:100]}",
|
title=f"New Complaint: {complaint.title[:100]}",
|
||||||
description=complaint.description[:500],
|
description=complaint.description[:500],
|
||||||
@ -313,11 +322,12 @@ def create_action_from_complaint(complaint_id):
|
|||||||
object_id=complaint.id,
|
object_id=complaint.id,
|
||||||
metadata={
|
metadata={
|
||||||
'complaint_id': str(complaint.id),
|
'complaint_id': str(complaint.id),
|
||||||
'complaint_category': complaint.category,
|
'complaint_category': category_name,
|
||||||
|
'complaint_category_id': category_id,
|
||||||
'complaint_severity': complaint.severity,
|
'complaint_severity': complaint.severity,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
from apps.core.services import create_audit_log
|
from apps.core.services import create_audit_log
|
||||||
create_audit_log(
|
create_audit_log(
|
||||||
@ -329,14 +339,14 @@ def create_action_from_complaint(complaint_id):
|
|||||||
'trigger': 'complaint_creation'
|
'trigger': 'complaint_creation'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Created PX Action {action.id} from complaint {complaint_id}")
|
logger.info(f"Created PX Action {action.id} from complaint {complaint_id}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'action_created',
|
'status': 'action_created',
|
||||||
'action_id': str(action.id)
|
'action_id': str(action.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
except Complaint.DoesNotExist:
|
except Complaint.DoesNotExist:
|
||||||
error_msg = f"Complaint {complaint_id} not found"
|
error_msg = f"Complaint {complaint_id} not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
@ -351,63 +361,63 @@ def create_action_from_complaint(complaint_id):
|
|||||||
def escalate_complaint_auto(complaint_id):
|
def escalate_complaint_auto(complaint_id):
|
||||||
"""
|
"""
|
||||||
Automatically escalate complaint based on escalation rules.
|
Automatically escalate complaint based on escalation rules.
|
||||||
|
|
||||||
This task is triggered when a complaint becomes overdue.
|
This task is triggered when a complaint becomes overdue.
|
||||||
It finds matching escalation rules and reassigns the complaint.
|
It finds matching escalation rules and reassigns the complaint.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
complaint_id: UUID of the Complaint
|
complaint_id: UUID of the Complaint
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with escalation status
|
dict: Result with escalation status
|
||||||
"""
|
"""
|
||||||
from apps.complaints.models import Complaint, ComplaintUpdate, EscalationRule
|
from apps.complaints.models import Complaint, ComplaintUpdate, EscalationRule
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
|
|
||||||
try:
|
try:
|
||||||
complaint = Complaint.objects.select_related(
|
complaint = Complaint.objects.select_related(
|
||||||
'hospital', 'department', 'assigned_to'
|
'hospital', 'department', 'assigned_to'
|
||||||
).get(id=complaint_id)
|
).get(id=complaint_id)
|
||||||
|
|
||||||
# Calculate hours overdue
|
# Calculate hours overdue
|
||||||
hours_overdue = (timezone.now() - complaint.due_at).total_seconds() / 3600
|
hours_overdue = (timezone.now() - complaint.due_at).total_seconds() / 3600
|
||||||
|
|
||||||
# Get applicable escalation rules for this hospital
|
# Get applicable escalation rules for this hospital
|
||||||
rules = EscalationRule.objects.filter(
|
rules = EscalationRule.objects.filter(
|
||||||
hospital=complaint.hospital,
|
hospital=complaint.hospital,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
trigger_on_overdue=True
|
trigger_on_overdue=True
|
||||||
).order_by('order')
|
).order_by('order')
|
||||||
|
|
||||||
# Filter rules by severity and priority if specified
|
# Filter rules by severity and priority if specified
|
||||||
if complaint.severity:
|
if complaint.severity:
|
||||||
rules = rules.filter(
|
rules = rules.filter(
|
||||||
Q(severity_filter='') | Q(severity_filter=complaint.severity)
|
Q(severity_filter='') | Q(severity_filter=complaint.severity)
|
||||||
)
|
)
|
||||||
|
|
||||||
if complaint.priority:
|
if complaint.priority:
|
||||||
rules = rules.filter(
|
rules = rules.filter(
|
||||||
Q(priority_filter='') | Q(priority_filter=complaint.priority)
|
Q(priority_filter='') | Q(priority_filter=complaint.priority)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find first matching rule based on hours overdue
|
# Find first matching rule based on hours overdue
|
||||||
matching_rule = None
|
matching_rule = None
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
if hours_overdue >= rule.trigger_hours_overdue:
|
if hours_overdue >= rule.trigger_hours_overdue:
|
||||||
matching_rule = rule
|
matching_rule = rule
|
||||||
break
|
break
|
||||||
|
|
||||||
if not matching_rule:
|
if not matching_rule:
|
||||||
logger.info(f"No matching escalation rule found for complaint {complaint_id}")
|
logger.info(f"No matching escalation rule found for complaint {complaint_id}")
|
||||||
return {'status': 'no_matching_rule'}
|
return {'status': 'no_matching_rule'}
|
||||||
|
|
||||||
# Determine escalation target
|
# Determine escalation target
|
||||||
escalation_target = None
|
escalation_target = None
|
||||||
|
|
||||||
if matching_rule.escalate_to_role == 'department_manager':
|
if matching_rule.escalate_to_role == 'department_manager':
|
||||||
if complaint.department and complaint.department.manager:
|
if complaint.department and complaint.department.manager:
|
||||||
escalation_target = complaint.department.manager
|
escalation_target = complaint.department.manager
|
||||||
|
|
||||||
elif matching_rule.escalate_to_role == 'hospital_admin':
|
elif matching_rule.escalate_to_role == 'hospital_admin':
|
||||||
# Find hospital admin for this hospital
|
# Find hospital admin for this hospital
|
||||||
escalation_target = User.objects.filter(
|
escalation_target = User.objects.filter(
|
||||||
@ -415,30 +425,30 @@ def escalate_complaint_auto(complaint_id):
|
|||||||
groups__name='Hospital Admin',
|
groups__name='Hospital Admin',
|
||||||
is_active=True
|
is_active=True
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
elif matching_rule.escalate_to_role == 'px_admin':
|
elif matching_rule.escalate_to_role == 'px_admin':
|
||||||
# Find PX admin
|
# Find PX admin
|
||||||
escalation_target = User.objects.filter(
|
escalation_target = User.objects.filter(
|
||||||
groups__name='PX Admin',
|
groups__name='PX Admin',
|
||||||
is_active=True
|
is_active=True
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
elif matching_rule.escalate_to_role == 'specific_user':
|
elif matching_rule.escalate_to_role == 'specific_user':
|
||||||
escalation_target = matching_rule.escalate_to_user
|
escalation_target = matching_rule.escalate_to_user
|
||||||
|
|
||||||
if not escalation_target:
|
if not escalation_target:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Could not find escalation target for rule {matching_rule.name} "
|
f"Could not find escalation target for rule {matching_rule.name} "
|
||||||
f"on complaint {complaint_id}"
|
f"on complaint {complaint_id}"
|
||||||
)
|
)
|
||||||
return {'status': 'no_target_found', 'rule': matching_rule.name}
|
return {'status': 'no_target_found', 'rule': matching_rule.name}
|
||||||
|
|
||||||
# Perform escalation
|
# Perform escalation
|
||||||
old_assignee = complaint.assigned_to
|
old_assignee = complaint.assigned_to
|
||||||
complaint.assigned_to = escalation_target
|
complaint.assigned_to = escalation_target
|
||||||
complaint.escalated_at = timezone.now()
|
complaint.escalated_at = timezone.now()
|
||||||
complaint.save(update_fields=['assigned_to', 'escalated_at'])
|
complaint.save(update_fields=['assigned_to', 'escalated_at'])
|
||||||
|
|
||||||
# Create update
|
# Create update
|
||||||
ComplaintUpdate.objects.create(
|
ComplaintUpdate.objects.create(
|
||||||
complaint=complaint,
|
complaint=complaint,
|
||||||
@ -457,13 +467,13 @@ def escalate_complaint_auto(complaint_id):
|
|||||||
'new_assignee_id': str(escalation_target.id)
|
'new_assignee_id': str(escalation_target.id)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send notifications
|
# Send notifications
|
||||||
send_complaint_notification.delay(
|
send_complaint_notification.delay(
|
||||||
complaint_id=str(complaint.id),
|
complaint_id=str(complaint.id),
|
||||||
event_type='escalated'
|
event_type='escalated'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
from apps.core.services import create_audit_log
|
from apps.core.services import create_audit_log
|
||||||
create_audit_log(
|
create_audit_log(
|
||||||
@ -476,19 +486,19 @@ def escalate_complaint_auto(complaint_id):
|
|||||||
'escalated_to': escalation_target.get_full_name()
|
'escalated_to': escalation_target.get_full_name()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Escalated complaint {complaint_id} to {escalation_target.get_full_name()} "
|
f"Escalated complaint {complaint_id} to {escalation_target.get_full_name()} "
|
||||||
f"using rule '{matching_rule.name}'"
|
f"using rule '{matching_rule.name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'escalated',
|
'status': 'escalated',
|
||||||
'rule': matching_rule.name,
|
'rule': matching_rule.name,
|
||||||
'escalated_to': escalation_target.get_full_name(),
|
'escalated_to': escalation_target.get_full_name(),
|
||||||
'hours_overdue': round(hours_overdue, 2)
|
'hours_overdue': round(hours_overdue, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
except Complaint.DoesNotExist:
|
except Complaint.DoesNotExist:
|
||||||
error_msg = f"Complaint {complaint_id} not found"
|
error_msg = f"Complaint {complaint_id} not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
@ -499,56 +509,232 @@ def escalate_complaint_auto(complaint_id):
|
|||||||
return {'status': 'error', 'reason': error_msg}
|
return {'status': 'error', 'reason': error_msg}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def analyze_complaint_with_ai(complaint_id):
|
||||||
|
"""
|
||||||
|
Analyze a complaint using AI to determine severity and priority and category.
|
||||||
|
|
||||||
|
This task is triggered when a complaint is created.
|
||||||
|
It uses the AI service to analyze the complaint content and classify it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
complaint_id: UUID of the Complaint
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result with severity, priority, category, and reasoning
|
||||||
|
"""
|
||||||
|
from apps.complaints.models import Complaint
|
||||||
|
from apps.core.ai_service import AIService, AIServiceError
|
||||||
|
|
||||||
|
try:
|
||||||
|
complaint = Complaint.objects.select_related('hospital').get(id=complaint_id)
|
||||||
|
|
||||||
|
logger.info(f"Starting AI analysis for complaint {complaint_id}")
|
||||||
|
|
||||||
|
# Get category name if category exists
|
||||||
|
category_name = None
|
||||||
|
if complaint.category:
|
||||||
|
category_name = complaint.category.name_en
|
||||||
|
|
||||||
|
# Analyze complaint using AI service
|
||||||
|
try:
|
||||||
|
analysis = AIService.analyze_complaint(
|
||||||
|
title=complaint.title,
|
||||||
|
description=complaint.description,
|
||||||
|
category=category_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Analyze emotion using AI service
|
||||||
|
emotion_analysis = AIService.analyze_emotion(
|
||||||
|
text=complaint.description
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update complaint with AI-determined values
|
||||||
|
old_severity = complaint.severity
|
||||||
|
old_priority = complaint.priority
|
||||||
|
old_category = complaint.category
|
||||||
|
old_department = complaint.department
|
||||||
|
|
||||||
|
complaint.severity = analysis['severity']
|
||||||
|
complaint.priority = analysis['priority']
|
||||||
|
|
||||||
|
from apps.complaints.models import ComplaintCategory
|
||||||
|
if category := ComplaintCategory.objects.filter(name_en=analysis['category']).first():
|
||||||
|
complaint.category = category
|
||||||
|
|
||||||
|
# Update department from AI analysis
|
||||||
|
department_name = analysis.get('department', '')
|
||||||
|
if department_name:
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
if department := Department.objects.filter(
|
||||||
|
hospital_id=complaint.hospital.id,
|
||||||
|
name=department_name
|
||||||
|
).first():
|
||||||
|
complaint.department = department
|
||||||
|
|
||||||
|
# Update title from AI analysis (use English version)
|
||||||
|
if analysis.get('title_en'):
|
||||||
|
complaint.title = analysis['title_en']
|
||||||
|
elif analysis.get('title'):
|
||||||
|
complaint.title = analysis['title']
|
||||||
|
|
||||||
|
# Save reasoning in metadata
|
||||||
|
# Use JSON-serializable values instead of model objects
|
||||||
|
old_category_name = old_category.name_en if old_category else None
|
||||||
|
old_category_id = str(old_category.id) if old_category else None
|
||||||
|
old_department_name = old_department.name if old_department else None
|
||||||
|
old_department_id = str(old_department.id) if old_department else None
|
||||||
|
|
||||||
|
# Initialize metadata if needed
|
||||||
|
if not complaint.metadata:
|
||||||
|
complaint.metadata = {}
|
||||||
|
|
||||||
|
# Update or create ai_analysis in metadata with bilingual support and emotion
|
||||||
|
complaint.metadata['ai_analysis'] = {
|
||||||
|
'title_en': analysis.get('title_en', ''),
|
||||||
|
'title_ar': analysis.get('title_ar', ''),
|
||||||
|
'short_description_en': analysis.get('short_description_en', ''),
|
||||||
|
'short_description_ar': analysis.get('short_description_ar', ''),
|
||||||
|
'suggested_action_en': analysis.get('suggested_action_en', ''),
|
||||||
|
'suggested_action_ar': analysis.get('suggested_action_ar', ''),
|
||||||
|
'reasoning_en': analysis.get('reasoning_en', ''),
|
||||||
|
'reasoning_ar': analysis.get('reasoning_ar', ''),
|
||||||
|
'emotion': emotion_analysis.get('emotion', 'neutral'),
|
||||||
|
'emotion_intensity': emotion_analysis.get('intensity', 0.0),
|
||||||
|
'emotion_confidence': emotion_analysis.get('confidence', 0.0),
|
||||||
|
'analyzed_at': timezone.now().isoformat(),
|
||||||
|
'old_severity': old_severity,
|
||||||
|
'old_priority': old_priority,
|
||||||
|
'old_category': old_category_name,
|
||||||
|
'old_category_id': old_category_id,
|
||||||
|
'old_department': old_department_name,
|
||||||
|
'old_department_id': old_department_id
|
||||||
|
}
|
||||||
|
|
||||||
|
complaint.save(update_fields=['severity', 'priority', 'category', 'department', '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'])
|
||||||
|
|
||||||
|
# Create timeline update for AI completion
|
||||||
|
from apps.complaints.models import ComplaintUpdate
|
||||||
|
|
||||||
|
# Build bilingual message
|
||||||
|
emotion_display = emotion_analysis.get('emotion', 'neutral')
|
||||||
|
emotion_intensity = emotion_analysis.get('intensity', 0.0)
|
||||||
|
|
||||||
|
message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}, Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})"
|
||||||
|
message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}, العاطفة={emotion_display} (الشدة: {emotion_intensity:.2f})"
|
||||||
|
message = f"{message_en}\n\n{message_ar}"
|
||||||
|
|
||||||
|
ComplaintUpdate.objects.create(
|
||||||
|
complaint=complaint,
|
||||||
|
update_type='note',
|
||||||
|
message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"AI analysis complete for complaint {complaint_id}: "
|
||||||
|
f"severity={old_severity}->{analysis['severity']}, "
|
||||||
|
f"priority={old_priority}->{analysis['priority']}, "
|
||||||
|
f"category={old_category_name}->{analysis['category']}, "
|
||||||
|
f"department={old_department_name}->{department_name}, "
|
||||||
|
f"title_en={analysis.get('title_en', '')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'complaint_id': str(complaint_id),
|
||||||
|
'severity': analysis['severity'],
|
||||||
|
'priority': analysis['priority'],
|
||||||
|
'category': analysis['category'],
|
||||||
|
'department': department_name,
|
||||||
|
'title_en': analysis.get('title_en', ''),
|
||||||
|
'title_ar': analysis.get('title_ar', ''),
|
||||||
|
'short_description_en': analysis.get('short_description_en', ''),
|
||||||
|
'short_description_ar': analysis.get('short_description_ar', ''),
|
||||||
|
'suggested_action_en': analysis.get('suggested_action_en', ''),
|
||||||
|
'suggested_action_ar': analysis.get('suggested_action_ar', ''),
|
||||||
|
'reasoning_en': analysis.get('reasoning_en', ''),
|
||||||
|
'reasoning_ar': analysis.get('reasoning_ar', ''),
|
||||||
|
'emotion': emotion_analysis.get('emotion', 'neutral'),
|
||||||
|
'emotion_intensity': emotion_analysis.get('intensity', 0.0),
|
||||||
|
'emotion_confidence': emotion_analysis.get('confidence', 0.0),
|
||||||
|
'old_severity': old_severity,
|
||||||
|
'old_priority': old_priority
|
||||||
|
}
|
||||||
|
|
||||||
|
except AIServiceError as e:
|
||||||
|
logger.error(f"AI service error for complaint {complaint_id}: {str(e)}")
|
||||||
|
# Keep default values (medium/medium) and log the error
|
||||||
|
return {
|
||||||
|
'status': 'ai_error',
|
||||||
|
'complaint_id': str(complaint_id),
|
||||||
|
'reason': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Complaint.DoesNotExist:
|
||||||
|
error_msg = f"Complaint {complaint_id} not found"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {'status': 'error', 'reason': error_msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error analyzing complaint {complaint_id} with AI: {str(e)}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
return {'status': 'error', 'reason': error_msg}
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_complaint_notification(complaint_id, event_type):
|
def send_complaint_notification(complaint_id, event_type):
|
||||||
"""
|
"""
|
||||||
Send notification for complaint events.
|
Send notification for complaint events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
complaint_id: UUID of the Complaint
|
complaint_id: UUID of the Complaint
|
||||||
event_type: Type of event (created, assigned, overdue, escalated, resolved, closed)
|
event_type: Type of event (created, assigned, overdue, escalated, resolved, closed)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with notification status
|
dict: Result with notification status
|
||||||
"""
|
"""
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
|
|
||||||
try:
|
try:
|
||||||
complaint = Complaint.objects.select_related(
|
complaint = Complaint.objects.select_related(
|
||||||
'hospital', 'patient', 'assigned_to', 'department'
|
'hospital', 'patient', 'assigned_to', 'department'
|
||||||
).get(id=complaint_id)
|
).get(id=complaint_id)
|
||||||
|
|
||||||
# Determine recipients based on event type
|
# Determine recipients based on event type
|
||||||
recipients = []
|
recipients = []
|
||||||
|
|
||||||
if event_type == 'created':
|
if event_type == 'created':
|
||||||
# Notify assigned user or department manager
|
# Notify assigned user or department manager
|
||||||
if complaint.assigned_to:
|
if complaint.assigned_to:
|
||||||
recipients.append(complaint.assigned_to)
|
recipients.append(complaint.assigned_to)
|
||||||
elif complaint.department and complaint.department.manager:
|
elif complaint.department and complaint.department.manager:
|
||||||
recipients.append(complaint.department.manager)
|
recipients.append(complaint.department.manager)
|
||||||
|
|
||||||
elif event_type == 'assigned':
|
elif event_type == 'assigned':
|
||||||
# Notify the assignee
|
# Notify the assignee
|
||||||
if complaint.assigned_to:
|
if complaint.assigned_to:
|
||||||
recipients.append(complaint.assigned_to)
|
recipients.append(complaint.assigned_to)
|
||||||
|
|
||||||
elif event_type in ['overdue', 'escalated']:
|
elif event_type in ['overdue', 'escalated']:
|
||||||
# Notify assignee and their manager
|
# Notify assignee and their manager
|
||||||
if complaint.assigned_to:
|
if complaint.assigned_to:
|
||||||
recipients.append(complaint.assigned_to)
|
recipients.append(complaint.assigned_to)
|
||||||
if complaint.department and complaint.department.manager:
|
if complaint.department and complaint.department.manager:
|
||||||
recipients.append(complaint.department.manager)
|
recipients.append(complaint.department.manager)
|
||||||
|
|
||||||
elif event_type == 'resolved':
|
elif event_type == 'resolved':
|
||||||
# Notify patient
|
# Notify patient
|
||||||
recipients.append(complaint.patient)
|
recipients.append(complaint.patient)
|
||||||
|
|
||||||
elif event_type == 'closed':
|
elif event_type == 'closed':
|
||||||
# Notify patient
|
# Notify patient
|
||||||
recipients.append(complaint.patient)
|
recipients.append(complaint.patient)
|
||||||
|
|
||||||
# Send notifications
|
# Send notifications
|
||||||
notification_count = 0
|
notification_count = 0
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
@ -567,15 +753,15 @@ def send_complaint_notification(complaint_id, event_type):
|
|||||||
logger.warning(f"NotificationService.send_notification method not available")
|
logger.warning(f"NotificationService.send_notification method not available")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send notification to {recipient}: {str(e)}")
|
logger.error(f"Failed to send notification to {recipient}: {str(e)}")
|
||||||
|
|
||||||
logger.info(f"Sent {notification_count} notifications for complaint {complaint_id} event: {event_type}")
|
logger.info(f"Sent {notification_count} notifications for complaint {complaint_id} event: {event_type}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'sent',
|
'status': 'sent',
|
||||||
'notification_count': notification_count,
|
'notification_count': notification_count,
|
||||||
'event_type': event_type
|
'event_type': event_type
|
||||||
}
|
}
|
||||||
|
|
||||||
except Complaint.DoesNotExist:
|
except Complaint.DoesNotExist:
|
||||||
error_msg = f"Complaint {complaint_id} not found"
|
error_msg = f"Complaint {complaint_id} not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|||||||
3
apps/complaints/templatetags/__init__.py
Normal file
3
apps/complaints/templatetags/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Complaints template tags
|
||||||
|
"""
|
||||||
33
apps/complaints/templatetags/math.py
Normal file
33
apps/complaints/templatetags/math.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Math template filters for complaints
|
||||||
|
"""
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def mul(value, arg):
|
||||||
|
"""Multiply the value by the argument"""
|
||||||
|
try:
|
||||||
|
return float(value) * float(arg)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def div(value, arg):
|
||||||
|
"""Divide the value by the argument"""
|
||||||
|
try:
|
||||||
|
return float(value) / float(arg)
|
||||||
|
except (ValueError, TypeError, ZeroDivisionError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def sub(value, arg):
|
||||||
|
"""Subtract the argument from the value"""
|
||||||
|
try:
|
||||||
|
return float(value) - float(arg)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
File diff suppressed because it is too large
Load Diff
@ -20,16 +20,16 @@ urlpatterns = [
|
|||||||
path('<uuid:pk>/change-status/', ui_views.complaint_change_status, name='complaint_change_status'),
|
path('<uuid:pk>/change-status/', ui_views.complaint_change_status, name='complaint_change_status'),
|
||||||
path('<uuid:pk>/add-note/', ui_views.complaint_add_note, name='complaint_add_note'),
|
path('<uuid:pk>/add-note/', ui_views.complaint_add_note, name='complaint_add_note'),
|
||||||
path('<uuid:pk>/escalate/', ui_views.complaint_escalate, name='complaint_escalate'),
|
path('<uuid:pk>/escalate/', ui_views.complaint_escalate, name='complaint_escalate'),
|
||||||
|
|
||||||
# Export Views
|
# Export Views
|
||||||
path('export/csv/', ui_views.complaint_export_csv, name='complaint_export_csv'),
|
path('export/csv/', ui_views.complaint_export_csv, name='complaint_export_csv'),
|
||||||
path('export/excel/', ui_views.complaint_export_excel, name='complaint_export_excel'),
|
path('export/excel/', ui_views.complaint_export_excel, name='complaint_export_excel'),
|
||||||
|
|
||||||
# Bulk Actions
|
# Bulk Actions
|
||||||
path('bulk/assign/', ui_views.complaint_bulk_assign, name='complaint_bulk_assign'),
|
path('bulk/assign/', ui_views.complaint_bulk_assign, name='complaint_bulk_assign'),
|
||||||
path('bulk/status/', ui_views.complaint_bulk_status, name='complaint_bulk_status'),
|
path('bulk/status/', ui_views.complaint_bulk_status, name='complaint_bulk_status'),
|
||||||
path('bulk/escalate/', ui_views.complaint_bulk_escalate, name='complaint_bulk_escalate'),
|
path('bulk/escalate/', ui_views.complaint_bulk_escalate, name='complaint_bulk_escalate'),
|
||||||
|
|
||||||
# Inquiries UI Views
|
# Inquiries UI Views
|
||||||
path('inquiries/', ui_views.inquiry_list, name='inquiry_list'),
|
path('inquiries/', ui_views.inquiry_list, name='inquiry_list'),
|
||||||
path('inquiries/new/', ui_views.inquiry_create, name='inquiry_create'),
|
path('inquiries/new/', ui_views.inquiry_create, name='inquiry_create'),
|
||||||
@ -38,15 +38,22 @@ urlpatterns = [
|
|||||||
path('inquiries/<uuid:pk>/change-status/', ui_views.inquiry_change_status, name='inquiry_change_status'),
|
path('inquiries/<uuid:pk>/change-status/', ui_views.inquiry_change_status, name='inquiry_change_status'),
|
||||||
path('inquiries/<uuid:pk>/add-note/', ui_views.inquiry_add_note, name='inquiry_add_note'),
|
path('inquiries/<uuid:pk>/add-note/', ui_views.inquiry_add_note, name='inquiry_add_note'),
|
||||||
path('inquiries/<uuid:pk>/respond/', ui_views.inquiry_respond, name='inquiry_respond'),
|
path('inquiries/<uuid:pk>/respond/', ui_views.inquiry_respond, name='inquiry_respond'),
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
path('analytics/', ui_views.complaints_analytics, name='complaints_analytics'),
|
path('analytics/', ui_views.complaints_analytics, name='complaints_analytics'),
|
||||||
|
|
||||||
# AJAX Helpers
|
# AJAX Helpers
|
||||||
path('ajax/departments/', ui_views.get_departments_by_hospital, name='get_departments_by_hospital'),
|
path('ajax/departments/', ui_views.get_departments_by_hospital, name='get_departments_by_hospital'),
|
||||||
path('ajax/physicians/', ui_views.get_physicians_by_department, name='get_physicians_by_department'),
|
path('ajax/physicians/', ui_views.get_staff_by_department, name='get_physicians_by_department'),
|
||||||
path('ajax/search-patients/', ui_views.search_patients, name='search_patients'),
|
path('ajax/search-patients/', ui_views.search_patients, name='search_patients'),
|
||||||
|
|
||||||
|
# Public Complaint Form (No Authentication Required)
|
||||||
|
path('public/submit/', ui_views.public_complaint_submit, name='public_complaint_submit'),
|
||||||
|
path('public/success/<str:reference>/', ui_views.public_complaint_success, name='public_complaint_success'),
|
||||||
|
path('public/api/lookup-patient/', ui_views.api_lookup_patient, name='api_lookup_patient'),
|
||||||
|
path('public/api/load-departments/', ui_views.api_load_departments, name='api_load_departments'),
|
||||||
|
path('public/api/load-categories/', ui_views.api_load_categories, name='api_load_categories'),
|
||||||
|
|
||||||
# API Routes
|
# API Routes
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -22,7 +22,7 @@ from .serializers import (
|
|||||||
class ComplaintViewSet(viewsets.ModelViewSet):
|
class ComplaintViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Complaints with workflow actions.
|
ViewSet for Complaints with workflow actions.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- All authenticated users can view complaints
|
- All authenticated users can view complaints
|
||||||
- PX Admins and Hospital Admins can create/manage complaints
|
- PX Admins and Hospital Admins can create/manage complaints
|
||||||
@ -32,49 +32,49 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
filterset_fields = [
|
filterset_fields = [
|
||||||
'status', 'severity', 'priority', 'category', 'source',
|
'status', 'severity', 'priority', 'category', 'source',
|
||||||
'hospital', 'department', 'physician', 'assigned_to',
|
'hospital', 'department', 'physician', 'assigned_to',
|
||||||
'is_overdue'
|
'is_overdue', 'hospital__organization'
|
||||||
]
|
]
|
||||||
search_fields = ['title', 'description', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
search_fields = ['title', 'description', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
||||||
ordering_fields = ['created_at', 'due_at', 'severity']
|
ordering_fields = ['created_at', 'due_at', 'severity']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
"""Use simplified serializer for list view"""
|
"""Use simplified serializer for list view"""
|
||||||
if self.action == 'list':
|
if self.action == 'list':
|
||||||
return ComplaintListSerializer
|
return ComplaintListSerializer
|
||||||
return ComplaintSerializer
|
return ComplaintSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter complaints based on user role"""
|
"""Filter complaints based on user role"""
|
||||||
queryset = super().get_queryset().select_related(
|
queryset = super().get_queryset().select_related(
|
||||||
'patient', 'hospital', 'department', 'physician',
|
'patient', 'hospital', 'department', 'physician',
|
||||||
'assigned_to', 'resolved_by', 'closed_by'
|
'assigned_to', 'resolved_by', 'closed_by'
|
||||||
).prefetch_related('attachments', 'updates')
|
).prefetch_related('attachments', 'updates')
|
||||||
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all complaints
|
# PX Admins see all complaints
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see complaints for their hospital
|
# Hospital Admins see complaints for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Department Managers see complaints for their department
|
# Department Managers see complaints for their department
|
||||||
if user.is_department_manager() and user.department:
|
if user.is_department_manager() and user.department:
|
||||||
return queryset.filter(department=user.department)
|
return queryset.filter(department=user.department)
|
||||||
|
|
||||||
# Others see complaints for their hospital
|
# Others see complaints for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Log complaint creation and trigger resolution satisfaction survey"""
|
"""Log complaint creation and trigger resolution satisfaction survey"""
|
||||||
complaint = serializer.save()
|
complaint = serializer.save()
|
||||||
|
|
||||||
AuditService.log_from_request(
|
AuditService.log_from_request(
|
||||||
event_type='complaint_created',
|
event_type='complaint_created',
|
||||||
description=f"Complaint created: {complaint.title}",
|
description=f"Complaint created: {complaint.title}",
|
||||||
@ -86,30 +86,30 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
'patient_mrn': complaint.patient.mrn
|
'patient_mrn': complaint.patient.mrn
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Optionally create PX Action (Phase 6)
|
# TODO: Optionally create PX Action (Phase 6)
|
||||||
# from apps.complaints.tasks import create_action_from_complaint
|
# from apps.complaints.tasks import create_action_from_complaint
|
||||||
# create_action_from_complaint.delay(str(complaint.id))
|
# create_action_from_complaint.delay(str(complaint.id))
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def assign(self, request, pk=None):
|
def assign(self, request, pk=None):
|
||||||
"""Assign complaint to user"""
|
"""Assign complaint to user"""
|
||||||
complaint = self.get_object()
|
complaint = self.get_object()
|
||||||
user_id = request.data.get('user_id')
|
user_id = request.data.get('user_id')
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'user_id is required'},
|
{'error': 'user_id is required'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
complaint.assigned_to = user
|
complaint.assigned_to = user
|
||||||
complaint.assigned_at = timezone.now()
|
complaint.assigned_at = timezone.now()
|
||||||
complaint.save(update_fields=['assigned_to', 'assigned_at'])
|
complaint.save(update_fields=['assigned_to', 'assigned_at'])
|
||||||
|
|
||||||
# Create update
|
# Create update
|
||||||
ComplaintUpdate.objects.create(
|
ComplaintUpdate.objects.create(
|
||||||
complaint=complaint,
|
complaint=complaint,
|
||||||
@ -117,37 +117,37 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
message=f"Assigned to {user.get_full_name()}",
|
message=f"Assigned to {user.get_full_name()}",
|
||||||
created_by=request.user
|
created_by=request.user
|
||||||
)
|
)
|
||||||
|
|
||||||
AuditService.log_from_request(
|
AuditService.log_from_request(
|
||||||
event_type='assignment',
|
event_type='assignment',
|
||||||
description=f"Complaint assigned to {user.get_full_name()}",
|
description=f"Complaint assigned to {user.get_full_name()}",
|
||||||
request=request,
|
request=request,
|
||||||
content_object=complaint
|
content_object=complaint
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response({'message': 'Complaint assigned successfully'})
|
return Response({'message': 'Complaint assigned successfully'})
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'User not found'},
|
{'error': 'User not found'},
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def change_status(self, request, pk=None):
|
def change_status(self, request, pk=None):
|
||||||
"""Change complaint status"""
|
"""Change complaint status"""
|
||||||
complaint = self.get_object()
|
complaint = self.get_object()
|
||||||
new_status = request.data.get('status')
|
new_status = request.data.get('status')
|
||||||
note = request.data.get('note', '')
|
note = request.data.get('note', '')
|
||||||
|
|
||||||
if not new_status:
|
if not new_status:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'status is required'},
|
{'error': 'status is required'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
old_status = complaint.status
|
old_status = complaint.status
|
||||||
complaint.status = new_status
|
complaint.status = new_status
|
||||||
|
|
||||||
# Handle status-specific logic
|
# Handle status-specific logic
|
||||||
if new_status == 'resolved':
|
if new_status == 'resolved':
|
||||||
complaint.resolved_at = timezone.now()
|
complaint.resolved_at = timezone.now()
|
||||||
@ -155,13 +155,13 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
elif new_status == 'closed':
|
elif new_status == 'closed':
|
||||||
complaint.closed_at = timezone.now()
|
complaint.closed_at = timezone.now()
|
||||||
complaint.closed_by = request.user
|
complaint.closed_by = request.user
|
||||||
|
|
||||||
# Trigger resolution satisfaction survey
|
# Trigger resolution satisfaction survey
|
||||||
from apps.complaints.tasks import send_complaint_resolution_survey
|
from apps.complaints.tasks import send_complaint_resolution_survey
|
||||||
send_complaint_resolution_survey.delay(str(complaint.id))
|
send_complaint_resolution_survey.delay(str(complaint.id))
|
||||||
|
|
||||||
complaint.save()
|
complaint.save()
|
||||||
|
|
||||||
# Create update
|
# Create update
|
||||||
ComplaintUpdate.objects.create(
|
ComplaintUpdate.objects.create(
|
||||||
complaint=complaint,
|
complaint=complaint,
|
||||||
@ -171,7 +171,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
old_status=old_status,
|
old_status=old_status,
|
||||||
new_status=new_status
|
new_status=new_status
|
||||||
)
|
)
|
||||||
|
|
||||||
AuditService.log_from_request(
|
AuditService.log_from_request(
|
||||||
event_type='status_change',
|
event_type='status_change',
|
||||||
description=f"Complaint status changed from {old_status} to {new_status}",
|
description=f"Complaint status changed from {old_status} to {new_status}",
|
||||||
@ -179,21 +179,21 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
content_object=complaint,
|
content_object=complaint,
|
||||||
metadata={'old_status': old_status, 'new_status': new_status}
|
metadata={'old_status': old_status, 'new_status': new_status}
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response({'message': 'Status updated successfully'})
|
return Response({'message': 'Status updated successfully'})
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def add_note(self, request, pk=None):
|
def add_note(self, request, pk=None):
|
||||||
"""Add note to complaint"""
|
"""Add note to complaint"""
|
||||||
complaint = self.get_object()
|
complaint = self.get_object()
|
||||||
note = request.data.get('note')
|
note = request.data.get('note')
|
||||||
|
|
||||||
if not note:
|
if not note:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'note is required'},
|
{'error': 'note is required'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create update
|
# Create update
|
||||||
update = ComplaintUpdate.objects.create(
|
update = ComplaintUpdate.objects.create(
|
||||||
complaint=complaint,
|
complaint=complaint,
|
||||||
@ -201,7 +201,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
message=note,
|
message=note,
|
||||||
created_by=request.user
|
created_by=request.user
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = ComplaintUpdateSerializer(update)
|
serializer = ComplaintUpdateSerializer(update)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@ -213,21 +213,21 @@ class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['complaint']
|
filterset_fields = ['complaint']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset().select_related('complaint', 'uploaded_by')
|
queryset = super().get_queryset().select_related('complaint', 'uploaded_by')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# Filter based on complaint access
|
# Filter based on complaint access
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(complaint__hospital=user.hospital)
|
return queryset.filter(complaint__hospital=user.hospital)
|
||||||
|
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(complaint__hospital=user.hospital)
|
return queryset.filter(complaint__hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
@ -236,53 +236,53 @@ class InquiryViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = Inquiry.objects.all()
|
queryset = Inquiry.objects.all()
|
||||||
serializer_class = InquirySerializer
|
serializer_class = InquirySerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['status', 'category', 'hospital', 'department', 'assigned_to']
|
filterset_fields = ['status', 'category', 'hospital', 'department', 'assigned_to', 'hospital__organization']
|
||||||
search_fields = ['subject', 'message', 'contact_name', 'patient__mrn']
|
search_fields = ['subject', 'message', 'contact_name', 'patient__mrn']
|
||||||
ordering_fields = ['created_at']
|
ordering_fields = ['created_at']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter inquiries based on user role"""
|
"""Filter inquiries based on user role"""
|
||||||
queryset = super().get_queryset().select_related(
|
queryset = super().get_queryset().select_related(
|
||||||
'patient', 'hospital', 'department', 'assigned_to', 'responded_by'
|
'patient', 'hospital', 'department', 'assigned_to', 'responded_by'
|
||||||
)
|
)
|
||||||
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all inquiries
|
# PX Admins see all inquiries
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see inquiries for their hospital
|
# Hospital Admins see inquiries for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Department Managers see inquiries for their department
|
# Department Managers see inquiries for their department
|
||||||
if user.is_department_manager() and user.department:
|
if user.is_department_manager() and user.department:
|
||||||
return queryset.filter(department=user.department)
|
return queryset.filter(department=user.department)
|
||||||
|
|
||||||
# Others see inquiries for their hospital
|
# Others see inquiries for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def respond(self, request, pk=None):
|
def respond(self, request, pk=None):
|
||||||
"""Respond to inquiry"""
|
"""Respond to inquiry"""
|
||||||
inquiry = self.get_object()
|
inquiry = self.get_object()
|
||||||
response_text = request.data.get('response')
|
response_text = request.data.get('response')
|
||||||
|
|
||||||
if not response_text:
|
if not response_text:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'response is required'},
|
{'error': 'response is required'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
inquiry.response = response_text
|
inquiry.response = response_text
|
||||||
inquiry.responded_at = timezone.now()
|
inquiry.responded_at = timezone.now()
|
||||||
inquiry.responded_by = request.user
|
inquiry.responded_by = request.user
|
||||||
inquiry.status = 'resolved'
|
inquiry.status = 'resolved'
|
||||||
inquiry.save()
|
inquiry.save()
|
||||||
|
|
||||||
return Response({'message': 'Response submitted successfully'})
|
return Response({'message': 'Response submitted successfully'})
|
||||||
|
|||||||
584
apps/core/ai_service.py
Normal file
584
apps/core/ai_service.py
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
"""
|
||||||
|
AI Service - Base class for all AI interactions using LiteLLM
|
||||||
|
|
||||||
|
This module provides a unified interface for AI operations using LiteLLM
|
||||||
|
with OpenRouter as the provider. This replaces the stub AI engine.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Complaint analysis (severity, priority classification)
|
||||||
|
- Chat completion for general AI tasks
|
||||||
|
- Sentiment analysis
|
||||||
|
- Entity extraction
|
||||||
|
- Language detection
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AIServiceError(Exception):
|
||||||
|
"""Custom exception for AI service errors"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AIService:
|
||||||
|
"""
|
||||||
|
Base AI Service class using LiteLLM with OpenRouter.
|
||||||
|
|
||||||
|
This is the single source of truth for all AI interactions in the application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
|
OPENROUTER_API_KEY = "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"
|
||||||
|
# Default configuration
|
||||||
|
DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
|
||||||
|
DEFAULT_TEMPERATURE = 0.3
|
||||||
|
DEFAULT_MAX_TOKENS = 500
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Severity choices
|
||||||
|
SEVERITY_CHOICES = ['low', 'medium', 'high', 'critical']
|
||||||
|
|
||||||
|
# Priority choices
|
||||||
|
PRIORITY_CHOICES = ['low', 'medium', 'high']
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_api_key(cls) -> str:
|
||||||
|
"""Get OpenRouter API key from settings"""
|
||||||
|
# Use 'or' operator to fall back to DEFAULT_API_KEY when setting is empty or not set
|
||||||
|
api_key = cls.OPENROUTER_API_KEY
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = api_key
|
||||||
|
os.environ["OPENROUTER_API_BASE"] = cls.OPENROUTER_BASE_URL
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_model(cls) -> str:
|
||||||
|
"""Get AI model from settings"""
|
||||||
|
return getattr(settings, 'AI_MODEL') or cls.DEFAULT_MODEL
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_temperature(cls) -> float:
|
||||||
|
"""Get AI temperature from settings"""
|
||||||
|
return float(getattr(settings, 'AI_TEMPERATURE')) or cls.DEFAULT_TEMPERATURE
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_max_tokens(cls) -> int:
|
||||||
|
"""Get max tokens from settings"""
|
||||||
|
return int(getattr(settings, 'AI_MAX_TOKENS')) or cls.DEFAULT_MAX_TOKENS
|
||||||
|
@classmethod
|
||||||
|
def _get_complaint_categories(cls) -> List[str]:
|
||||||
|
"""Get complaint categories from settings"""
|
||||||
|
from apps.complaints.models import ComplaintCategory
|
||||||
|
|
||||||
|
return ComplaintCategory.objects.all().values_list('name_en', flat=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_complaint_sub_categories(cls, category) -> List[str]:
|
||||||
|
"""Get complaint subcategories for a given category name"""
|
||||||
|
from apps.complaints.models import ComplaintCategory
|
||||||
|
if category:
|
||||||
|
try:
|
||||||
|
# Find the category by name and get its subcategories
|
||||||
|
category_obj = ComplaintCategory.objects.filter(name_en=category).first()
|
||||||
|
if category_obj:
|
||||||
|
return ComplaintCategory.objects.filter(parent=category_obj).values_list('name_en', flat=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching subcategories: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_all_categories_with_subcategories(cls) -> Dict[str, List[str]]:
|
||||||
|
"""Get all categories with their subcategories in a structured format"""
|
||||||
|
from apps.complaints.models import ComplaintCategory
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
# Get all parent categories (no parent or parent is null)
|
||||||
|
parent_categories = ComplaintCategory.objects.filter(parent__isnull=True).all()
|
||||||
|
|
||||||
|
for category in parent_categories:
|
||||||
|
# Get subcategories for this parent
|
||||||
|
subcategories = list(
|
||||||
|
ComplaintCategory.objects.filter(parent=category).values_list('name_en', flat=True)
|
||||||
|
)
|
||||||
|
result[category.name_en] = subcategories if subcategories else []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching categories with subcategories: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_hospital_departments(cls, hospital_id: int) -> List[str]:
|
||||||
|
"""Get all departments for a specific hospital"""
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
|
||||||
|
try:
|
||||||
|
departments = Department.objects.filter(
|
||||||
|
hospital_id=hospital_id,
|
||||||
|
status='active'
|
||||||
|
).values_list('name', flat=True)
|
||||||
|
return list(departments)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching hospital departments: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def chat_completion(
|
||||||
|
cls,
|
||||||
|
prompt: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
response_format: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Perform a chat completion using LiteLLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: User prompt
|
||||||
|
model: AI model (uses default if not provided)
|
||||||
|
temperature: Temperature for randomness (uses default if not provided)
|
||||||
|
max_tokens: Maximum tokens to generate
|
||||||
|
system_prompt: System prompt to set context
|
||||||
|
response_format: Response format ('text' or 'json_object')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated text response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AIServiceError: If API call fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from litellm import completion
|
||||||
|
|
||||||
|
api_key = cls._get_api_key()
|
||||||
|
|
||||||
|
model_name = model or cls._get_model()
|
||||||
|
temp = temperature if temperature is not None else cls._get_temperature()
|
||||||
|
max_tok = max_tokens or cls._get_max_tokens()
|
||||||
|
|
||||||
|
# Build messages
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
|
# Build kwargs
|
||||||
|
kwargs = {
|
||||||
|
"model": "openrouter/xiaomi/mimo-v2-flash:free",
|
||||||
|
"messages": messages
|
||||||
|
}
|
||||||
|
|
||||||
|
if response_format:
|
||||||
|
kwargs["response_format"] = {"type": response_format}
|
||||||
|
|
||||||
|
logger.info(f"AI Request: model={model_name}, temp={temp}")
|
||||||
|
|
||||||
|
response = completion(**kwargs)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
logger.info(f"AI Response: length={len(content)}")
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI service error: {str(e)}")
|
||||||
|
raise AIServiceError(f"Failed to get AI response: {str(e)}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def analyze_complaint(
|
||||||
|
cls,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
description: str = "",
|
||||||
|
category: Optional[str] = None,
|
||||||
|
hospital_id: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze a complaint and determine title, severity, priority, category, subcategory, and department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Complaint title (optional, will be generated if not provided)
|
||||||
|
description: Complaint description
|
||||||
|
category: Complaint category
|
||||||
|
hospital_id: Hospital ID to fetch departments
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with analysis:
|
||||||
|
{
|
||||||
|
'title': str, # Generated or provided title
|
||||||
|
'short_description': str, # 2-3 sentence summary of the complaint
|
||||||
|
'severity': 'low' | 'medium' | 'high' | 'critical',
|
||||||
|
'priority': 'low' | 'medium' | 'high',
|
||||||
|
'category': str, # Name of the category
|
||||||
|
'subcategory': str, # Name of the subcategory
|
||||||
|
'department': str, # Name of the department
|
||||||
|
'reasoning': str # Explanation for the classification
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
cache_key = f"complaint_analysis:{hash(str(title) + description + str(hospital_id))}"
|
||||||
|
cached_result = cache.get(cache_key)
|
||||||
|
if cached_result:
|
||||||
|
logger.info("Using cached complaint analysis")
|
||||||
|
return cached_result
|
||||||
|
|
||||||
|
# Get categories with subcategories
|
||||||
|
categories_with_subcategories = cls._get_all_categories_with_subcategories()
|
||||||
|
|
||||||
|
# Format categories for the prompt
|
||||||
|
categories_text = ""
|
||||||
|
for cat, subcats in categories_with_subcategories.items():
|
||||||
|
if subcats:
|
||||||
|
categories_text += f"- {cat} (subcategories: {', '.join(subcats)})\n"
|
||||||
|
else:
|
||||||
|
categories_text += f"- {cat}\n"
|
||||||
|
|
||||||
|
# Get hospital departments if hospital_id is provided
|
||||||
|
departments_text = ""
|
||||||
|
if hospital_id:
|
||||||
|
departments = cls._get_hospital_departments(hospital_id)
|
||||||
|
if departments:
|
||||||
|
departments_text = f"\nAvailable Departments for this hospital:\n"
|
||||||
|
for dept in departments:
|
||||||
|
departments_text += f"- {dept}\n"
|
||||||
|
departments_text += "\n"
|
||||||
|
|
||||||
|
# Build prompt
|
||||||
|
title_text = f"Complaint Title: {title}\n" if title else ""
|
||||||
|
prompt = f"""Analyze this complaint and classify its severity, priority, category, subcategory, and department.
|
||||||
|
|
||||||
|
Complaint Description: {description}
|
||||||
|
{title_text}Current Category: {category or 'not specified'}{departments_text}Severity Classification (choose one):
|
||||||
|
- low: Minor issues, no impact on patient care, routine matters
|
||||||
|
- medium: Moderate issues, some patient dissatisfaction, not urgent
|
||||||
|
- high: Serious issues, significant patient impact, requires timely attention
|
||||||
|
- critical: Emergency, immediate threat to patient safety, requires instant action
|
||||||
|
|
||||||
|
Priority Classification (choose one):
|
||||||
|
- low: Can be addressed within 1-2 weeks
|
||||||
|
- medium: Should be addressed within 3-5 days
|
||||||
|
- high: Requires immediate attention (within 24 hours)
|
||||||
|
|
||||||
|
Available Categories and Subcategories:
|
||||||
|
{categories_text}
|
||||||
|
|
||||||
|
Instructions:
|
||||||
|
1. If no title is provided, generate a concise title (max 10 words) that summarizes the complaint in BOTH English and Arabic
|
||||||
|
2. Generate a short_description (2-3 sentences) that captures the main issue and context in BOTH English and Arabic
|
||||||
|
3. Select the most appropriate category from the list above
|
||||||
|
4. If the selected category has subcategories, choose the most relevant one
|
||||||
|
5. If a category has no subcategories, leave the subcategory field empty
|
||||||
|
6. Select the most appropriate department from the hospital's departments (if available)
|
||||||
|
7. If no departments are available or department is unclear, leave the department field empty
|
||||||
|
8. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic
|
||||||
|
|
||||||
|
IMPORTANT: ALL TEXT FIELDS MUST BE PROVIDED IN BOTH ENGLISH AND ARABIC
|
||||||
|
- title: Provide in both English and Arabic
|
||||||
|
- short_description: Provide in both English and Arabic
|
||||||
|
- suggested_action: Provide in both English and Arabic
|
||||||
|
- reasoning: Provide in both English and Arabic
|
||||||
|
|
||||||
|
Provide your analysis in JSON format:
|
||||||
|
{{
|
||||||
|
"title_en": "concise title in English summarizing the complaint (max 10 words)",
|
||||||
|
"title_ar": "العنوان بالعربية",
|
||||||
|
"short_description_en": "2-3 sentence summary in English of the complaint that captures the main issue and context",
|
||||||
|
"short_description_ar": "ملخص من 2-3 جمل بالعربية",
|
||||||
|
"severity": "low|medium|high|critical",
|
||||||
|
"priority": "low|medium|high",
|
||||||
|
"category": "exact category name from the list above",
|
||||||
|
"subcategory": "exact subcategory name from the chosen category, or empty string if not applicable",
|
||||||
|
"department": "exact department name from the hospital's departments, or empty string if not applicable",
|
||||||
|
"suggested_action_en": "2-3 specific, actionable steps in English to address this complaint",
|
||||||
|
"suggested_action_ar": "خطوات محددة وعمليه بالعربية",
|
||||||
|
"reasoning_en": "Brief explanation in English of your classification (2-3 sentences)",
|
||||||
|
"reasoning_ar": "شرح مختصر بالعربية"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
system_prompt = """You are a healthcare complaint analysis expert fluent in both English and Arabic.
|
||||||
|
Your job is to classify complaints based on their potential impact on patient care and safety.
|
||||||
|
Be conservative - when in doubt, choose a higher severity/priority.
|
||||||
|
Generate clear, concise titles that accurately summarize the complaint in BOTH English and Arabic.
|
||||||
|
Provide all text fields in both languages."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = cls.chat_completion(
|
||||||
|
prompt=prompt,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
response_format="json_object",
|
||||||
|
temperature=0.2 # Lower temperature for consistent classification
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
result = json.loads(response)
|
||||||
|
|
||||||
|
# Use provided title if available, otherwise use AI-generated title
|
||||||
|
if title:
|
||||||
|
result['title'] = title
|
||||||
|
|
||||||
|
# Validate severity
|
||||||
|
if result.get('severity') not in cls.SEVERITY_CHOICES:
|
||||||
|
result['severity'] = 'medium'
|
||||||
|
logger.warning(f"Invalid severity, defaulting to medium")
|
||||||
|
|
||||||
|
# Validate priority
|
||||||
|
if result.get('priority') not in cls.PRIORITY_CHOICES:
|
||||||
|
result['priority'] = 'medium'
|
||||||
|
logger.warning(f"Invalid priority, defaulting to medium")
|
||||||
|
|
||||||
|
# Validate category
|
||||||
|
if result.get('category') not in cls._get_complaint_categories():
|
||||||
|
result['category'] = 'other'
|
||||||
|
logger.warning(f"Invalid category, defaulting to 'Not specified'")
|
||||||
|
|
||||||
|
# Ensure title exists
|
||||||
|
if not result.get('title'):
|
||||||
|
result['title'] = 'Complaint'
|
||||||
|
|
||||||
|
# Cache result for 1 hour
|
||||||
|
cache.set(cache_key, result, timeout=3600)
|
||||||
|
|
||||||
|
logger.info(f"Complaint analyzed: title={result['title']}, severity={result['severity']}, priority={result['priority']}, department={result.get('department', 'N/A')}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse AI response: {e}")
|
||||||
|
# Return defaults
|
||||||
|
return {
|
||||||
|
'title': title or 'Complaint',
|
||||||
|
'severity': 'medium',
|
||||||
|
'priority': 'medium',
|
||||||
|
'category': 'other',
|
||||||
|
'subcategory': '',
|
||||||
|
'department': '',
|
||||||
|
'reasoning': 'AI analysis failed, using default values'
|
||||||
|
}
|
||||||
|
except AIServiceError as e:
|
||||||
|
logger.error(f"AI service error: {e}")
|
||||||
|
return {
|
||||||
|
'title': title or 'Complaint',
|
||||||
|
'severity': 'medium',
|
||||||
|
'priority': 'medium',
|
||||||
|
'category': 'other',
|
||||||
|
'subcategory': '',
|
||||||
|
'department': '',
|
||||||
|
'reasoning': f'AI service unavailable: {str(e)}'
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def classify_sentiment(
|
||||||
|
cls,
|
||||||
|
text: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Classify sentiment of text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to analyze
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with sentiment analysis:
|
||||||
|
{
|
||||||
|
'sentiment': 'positive' | 'neutral' | 'negative',
|
||||||
|
'score': float, # -1.0 to 1.0
|
||||||
|
'confidence': float # 0.0 to 1.0
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
prompt = f"""Analyze the sentiment of this text:
|
||||||
|
|
||||||
|
{text}
|
||||||
|
|
||||||
|
Provide your analysis in JSON format:
|
||||||
|
{{
|
||||||
|
"sentiment": "positive|neutral|negative",
|
||||||
|
"score": float, # -1.0 (very negative) to 1.0 (very positive)
|
||||||
|
"confidence": float # 0.0 to 1.0
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
system_prompt = """You are a sentiment analysis expert.
|
||||||
|
Analyze the emotional tone of the text accurately."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = cls.chat_completion(
|
||||||
|
prompt=prompt,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
response_format="json_object",
|
||||||
|
temperature=0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
result = json.loads(response)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, AIServiceError) as e:
|
||||||
|
logger.error(f"Sentiment analysis failed: {e}")
|
||||||
|
return {
|
||||||
|
'sentiment': 'neutral',
|
||||||
|
'score': 0.0,
|
||||||
|
'confidence': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def analyze_emotion(
|
||||||
|
cls,
|
||||||
|
text: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze emotion in text to identify primary emotion and intensity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to analyze (supports English and Arabic)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with emotion analysis:
|
||||||
|
{
|
||||||
|
'emotion': 'anger' | 'sadness' | 'confusion' | 'fear' | 'neutral',
|
||||||
|
'intensity': float, # 0.0 to 1.0 (how strong the emotion is)
|
||||||
|
'confidence': float # 0.0 to 1.0 (how confident AI is)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
prompt = f"""Analyze the primary emotion in this text (supports English and Arabic):
|
||||||
|
|
||||||
|
{text}
|
||||||
|
|
||||||
|
Identify the PRIMARY emotion from these options:
|
||||||
|
- anger: Strong feelings of displeasure, hostility, or rage
|
||||||
|
- sadness: Feelings of sorrow, grief, or unhappiness
|
||||||
|
- confusion: Lack of understanding, bewilderment, or uncertainty
|
||||||
|
- fear: Feelings of anxiety, worry, or being afraid
|
||||||
|
- neutral: No strong emotion detected
|
||||||
|
|
||||||
|
Provide your analysis in JSON format:
|
||||||
|
{{
|
||||||
|
"emotion": "anger|sadness|confusion|fear|neutral",
|
||||||
|
"intensity": float, # 0.0 (very weak) to 1.0 (extremely strong)
|
||||||
|
"confidence": float # 0.0 to 1.0 (how confident you are)
|
||||||
|
}}
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "This is unacceptable! I demand to speak to management!" -> emotion: "anger", intensity: 0.9
|
||||||
|
- "I'm very disappointed with the care my father received" -> emotion: "sadness", intensity: 0.7
|
||||||
|
- "I don't understand what happened, can you explain?" -> emotion: "confusion", intensity: 0.5
|
||||||
|
- "I'm worried about the side effects of this medication" -> emotion: "fear", intensity: 0.6
|
||||||
|
- "I would like to report a minor issue" -> emotion: "neutral", intensity: 0.2
|
||||||
|
"""
|
||||||
|
|
||||||
|
system_prompt = """You are an emotion analysis expert fluent in both English and Arabic.
|
||||||
|
Analyze the text to identify the PRIMARY emotion and its intensity.
|
||||||
|
Be accurate in distinguishing between different emotions.
|
||||||
|
Provide intensity scores that reflect how strongly the emotion is expressed (0.0 to 1.0)."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = cls.chat_completion(
|
||||||
|
prompt=prompt,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
response_format="json_object",
|
||||||
|
temperature=0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
result = json.loads(response)
|
||||||
|
|
||||||
|
# Validate emotion
|
||||||
|
valid_emotions = ['anger', 'sadness', 'confusion', 'fear', 'neutral']
|
||||||
|
if result.get('emotion') not in valid_emotions:
|
||||||
|
result['emotion'] = 'neutral'
|
||||||
|
logger.warning(f"Invalid emotion detected, defaulting to neutral")
|
||||||
|
|
||||||
|
# Validate intensity
|
||||||
|
intensity = float(result.get('intensity', 0.0))
|
||||||
|
if not (0.0 <= intensity <= 1.0):
|
||||||
|
intensity = max(0.0, min(1.0, intensity))
|
||||||
|
result['intensity'] = intensity
|
||||||
|
logger.warning(f"Intensity out of range, clamping to {intensity}")
|
||||||
|
|
||||||
|
# Validate confidence
|
||||||
|
confidence = float(result.get('confidence', 0.0))
|
||||||
|
if not (0.0 <= confidence <= 1.0):
|
||||||
|
confidence = max(0.0, min(1.0, confidence))
|
||||||
|
result['confidence'] = confidence
|
||||||
|
logger.warning(f"Confidence out of range, clamping to {confidence}")
|
||||||
|
|
||||||
|
logger.info(f"Emotion analysis: {result['emotion']}, intensity={intensity}, confidence={confidence}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, AIServiceError) as e:
|
||||||
|
logger.error(f"Emotion analysis failed: {e}")
|
||||||
|
return {
|
||||||
|
'emotion': 'neutral',
|
||||||
|
'intensity': 0.0,
|
||||||
|
'confidence': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def extract_entities(cls, text: str) -> List[Dict[str, str]]:
|
||||||
|
prompt = f"""Extract named entities from this text:
|
||||||
|
"{text}"
|
||||||
|
|
||||||
|
Focus heavily on PERSON names.
|
||||||
|
IMPORTANT: Extract the clean name only. Remove titles like 'Dr.', 'Nurse', 'Mr.', 'Professor', 'دكتور', 'ممرض'.
|
||||||
|
|
||||||
|
Provide entities in JSON format:
|
||||||
|
{{
|
||||||
|
"entities": [
|
||||||
|
{{"text": "Name", "type": "PERSON"}},
|
||||||
|
{{"text": "DepartmentName", "type": "ORGANIZATION"}}
|
||||||
|
]
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
system_prompt = "You are an expert in bilingual NER (Arabic and English). Extract formal names for database lookup."
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = cls.chat_completion(
|
||||||
|
prompt=prompt,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
response_format="json_object",
|
||||||
|
temperature=0.0
|
||||||
|
)
|
||||||
|
return json.loads(response).get('entities', [])
|
||||||
|
except (json.JSONDecodeError, AIServiceError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_summary(cls, text: str, max_length: int = 200) -> str:
|
||||||
|
"""
|
||||||
|
Generate a summary of text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to summarize
|
||||||
|
max_length: Maximum length of summary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary text
|
||||||
|
"""
|
||||||
|
prompt = f"""Summarize this text in {max_length} characters or less:
|
||||||
|
|
||||||
|
{text}"""
|
||||||
|
|
||||||
|
system_prompt = """You are a text summarization expert.
|
||||||
|
Create a concise summary that captures the main points."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = cls.chat_completion(
|
||||||
|
prompt=prompt,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=150
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.strip()
|
||||||
|
|
||||||
|
except AIServiceError as e:
|
||||||
|
logger.error(f"Summary generation failed: {e}")
|
||||||
|
return text[:max_length]
|
||||||
|
|
||||||
|
# Convenience singleton instance
|
||||||
|
ai_service = AIService()
|
||||||
@ -7,7 +7,7 @@ from django.db.models import Q
|
|||||||
def sidebar_counts(request):
|
def sidebar_counts(request):
|
||||||
"""
|
"""
|
||||||
Provide counts for sidebar badges.
|
Provide counts for sidebar badges.
|
||||||
|
|
||||||
Returns counts for:
|
Returns counts for:
|
||||||
- Active complaints
|
- Active complaints
|
||||||
- Pending feedback
|
- Pending feedback
|
||||||
@ -16,24 +16,34 @@ def sidebar_counts(request):
|
|||||||
"""
|
"""
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
from apps.feedback.models import Feedback
|
from apps.feedback.models import Feedback
|
||||||
from apps.px_action_center.models import PXAction
|
from apps.px_action_center.models import PXAction
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# Filter based on user role
|
# Filter based on user role and tenant_hospital
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
complaint_count = Complaint.objects.filter(
|
# PX Admins use their selected hospital from session
|
||||||
status__in=['open', 'in_progress']
|
hospital = getattr(request, 'tenant_hospital', None)
|
||||||
).count()
|
if hospital:
|
||||||
feedback_count = Feedback.objects.filter(
|
complaint_count = Complaint.objects.filter(
|
||||||
status__in=['submitted', 'reviewed']
|
hospital=hospital,
|
||||||
).count()
|
status__in=['open', 'in_progress']
|
||||||
action_count = PXAction.objects.filter(
|
).count()
|
||||||
status__in=['open', 'in_progress']
|
feedback_count = Feedback.objects.filter(
|
||||||
).count()
|
hospital=hospital,
|
||||||
|
status__in=['submitted', 'reviewed']
|
||||||
|
).count()
|
||||||
|
action_count = PXAction.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
status__in=['open', 'in_progress']
|
||||||
|
).count()
|
||||||
|
else:
|
||||||
|
complaint_count = 0
|
||||||
|
feedback_count = 0
|
||||||
|
action_count = 0
|
||||||
# Count provisional users for PX Admin
|
# Count provisional users for PX Admin
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
provisional_user_count = User.objects.filter(
|
provisional_user_count = User.objects.filter(
|
||||||
@ -58,11 +68,29 @@ def sidebar_counts(request):
|
|||||||
complaint_count = 0
|
complaint_count = 0
|
||||||
feedback_count = 0
|
feedback_count = 0
|
||||||
action_count = 0
|
action_count = 0
|
||||||
provisional_user_count = 0
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'complaint_count': complaint_count,
|
'complaint_count': complaint_count,
|
||||||
'feedback_count': feedback_count,
|
'feedback_count': feedback_count,
|
||||||
'action_count': action_count,
|
'action_count': action_count,
|
||||||
|
'current_hospital': getattr(request, 'tenant_hospital', None),
|
||||||
|
'is_px_admin': request.user.is_authenticated and request.user.is_px_admin(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hospital_context(request):
|
||||||
|
"""
|
||||||
|
Provide current hospital context to templates.
|
||||||
|
|
||||||
|
This ensures hospital information is available in the header for all pages.
|
||||||
|
"""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'current_hospital': hospital,
|
||||||
|
'is_px_admin': request.user.is_px_admin(),
|
||||||
'provisional_user_count': provisional_user_count,
|
'provisional_user_count': provisional_user_count,
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/core/managers.py
Normal file
31
apps/core/managers.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Tenant-aware managers and querysets for multi-tenancy
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class TenantQuerySet(models.QuerySet):
|
||||||
|
"""QuerySet that automatically filters by current tenant."""
|
||||||
|
|
||||||
|
def for_tenant(self, hospital):
|
||||||
|
"""Filter records for specific hospital."""
|
||||||
|
return self.filter(hospital=hospital)
|
||||||
|
|
||||||
|
def for_current_tenant(self, request):
|
||||||
|
"""Filter records for current request's tenant hospital."""
|
||||||
|
if hasattr(request, 'tenant_hospital') and request.tenant_hospital:
|
||||||
|
return self.filter(hospital=request.tenant_hospital)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TenantManager(models.Manager):
|
||||||
|
"""Manager that uses TenantQuerySet."""
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TenantQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
|
def for_tenant(self, hospital):
|
||||||
|
return self.get_queryset().for_tenant(hospital)
|
||||||
|
|
||||||
|
def for_current_tenant(self, request):
|
||||||
|
return self.get_queryset().for_current_tenant(request)
|
||||||
46
apps/core/middleware.py
Normal file
46
apps/core/middleware.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Tenant-aware middleware for multi-tenancy
|
||||||
|
"""
|
||||||
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TenantMiddleware(MiddlewareMixin):
|
||||||
|
"""
|
||||||
|
Middleware that sets the current hospital context from the authenticated user.
|
||||||
|
|
||||||
|
This middleware ensures that:
|
||||||
|
- authenticated users have their tenant_hospital set from their profile
|
||||||
|
- PX admins can switch between hospitals via session
|
||||||
|
- All requests have tenant context available
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process_request(self, request):
|
||||||
|
"""Set tenant hospital context on each request."""
|
||||||
|
if request.user and request.user.is_authenticated:
|
||||||
|
# Store user's role for quick access
|
||||||
|
request.user_roles = request.user.get_role_names()
|
||||||
|
|
||||||
|
# PX Admins can switch hospitals via session
|
||||||
|
if request.user.is_px_admin():
|
||||||
|
hospital_id = request.session.get('selected_hospital_id')
|
||||||
|
if hospital_id:
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
try:
|
||||||
|
# Validate that the hospital exists
|
||||||
|
request.tenant_hospital = Hospital.objects.get(id=hospital_id)
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
# Invalid hospital ID, fall back to default
|
||||||
|
request.tenant_hospital = None
|
||||||
|
# Clear invalid session data
|
||||||
|
request.session.pop('selected_hospital_id', None)
|
||||||
|
else:
|
||||||
|
# No hospital selected yet
|
||||||
|
request.tenant_hospital = None
|
||||||
|
else:
|
||||||
|
# Non-PX Admin users use their assigned hospital
|
||||||
|
request.tenant_hospital = request.user.hospital
|
||||||
|
else:
|
||||||
|
request.tenant_hospital = None
|
||||||
|
request.user_roles = []
|
||||||
|
|
||||||
|
return None
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:07
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
|||||||
185
apps/core/mixins.py
Normal file
185
apps/core/mixins.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Tenant-aware mixins for views and serializers
|
||||||
|
"""
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import Http404
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class TenantAccessMixin:
|
||||||
|
"""
|
||||||
|
Mixin that validates hospital access for views.
|
||||||
|
|
||||||
|
This mixin ensures:
|
||||||
|
- Users can only access objects from their hospital
|
||||||
|
- PX admins can access all hospitals
|
||||||
|
- Hospital admins can only access their hospital
|
||||||
|
- Department managers can only access their department
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
"""Retrieve object with tenant validation."""
|
||||||
|
obj = super().get_object(queryset)
|
||||||
|
|
||||||
|
# Check if user has access to this object's hospital
|
||||||
|
if hasattr(obj, 'hospital'):
|
||||||
|
if not self.can_access_hospital(obj.hospital):
|
||||||
|
raise PermissionDenied("You don't have access to this hospital's data")
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter queryset based on user's hospital and role."""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
# PX Admins can see all hospitals
|
||||||
|
if user.is_px_admin():
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
# Users without a hospital cannot see any records
|
||||||
|
if not user.hospital:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Filter by user's hospital
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
|
# Department managers can only see their department's records
|
||||||
|
if user.is_department_manager() and user.department:
|
||||||
|
if hasattr(queryset.model, 'department'):
|
||||||
|
queryset = queryset.filter(department=user.department)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def can_access_hospital(self, hospital):
|
||||||
|
"""Check if user can access given hospital."""
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
# PX Admins can access all hospitals
|
||||||
|
if user.is_px_admin():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Users can only access their own hospital
|
||||||
|
if user.hospital == hospital:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSerializerMixin:
|
||||||
|
"""
|
||||||
|
Mixin that validates hospital field in serializers.
|
||||||
|
|
||||||
|
This mixin ensures:
|
||||||
|
- Users can only create records for their hospital
|
||||||
|
- PX admins can create records for any hospital
|
||||||
|
- Hospital field is validated and set automatically
|
||||||
|
"""
|
||||||
|
|
||||||
|
def validate_hospital(self, value):
|
||||||
|
"""Ensure user can create records for this hospital."""
|
||||||
|
user = self.context['request'].user
|
||||||
|
|
||||||
|
# PX admins can assign to any hospital
|
||||||
|
if user.is_px_admin():
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Users must create records for their own hospital
|
||||||
|
if user.hospital != value:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"You can only create records for your hospital"
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
"""Set hospital from user's profile if not provided."""
|
||||||
|
# Convert data to mutable dict if needed
|
||||||
|
mutable_data = data.copy() if hasattr(data, 'copy') else data
|
||||||
|
|
||||||
|
user = self.context['request'].user
|
||||||
|
|
||||||
|
# Auto-set hospital if not provided and user has one
|
||||||
|
if 'hospital' not in mutable_data or not mutable_data['hospital']:
|
||||||
|
if user.hospital:
|
||||||
|
mutable_data['hospital'] = str(user.hospital.id)
|
||||||
|
|
||||||
|
return super().to_internal_value(mutable_data)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantAdminMixin:
|
||||||
|
"""
|
||||||
|
Mixin for Django admin with tenant isolation.
|
||||||
|
|
||||||
|
This mixin ensures:
|
||||||
|
- Admin users only see their hospital's records
|
||||||
|
- PX admins see all records
|
||||||
|
- New records are automatically assigned to user's hospital
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Filter queryset based on user's hospital."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
|
||||||
|
# PX Admins can see all hospitals
|
||||||
|
if request.user.is_px_admin():
|
||||||
|
return qs
|
||||||
|
|
||||||
|
# Users with a hospital can only see their hospital's records
|
||||||
|
if request.user.hospital:
|
||||||
|
qs = qs.filter(hospital=request.user.hospital)
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
"""Auto-assign hospital on create."""
|
||||||
|
if not change and hasattr(obj, 'hospital') and not obj.hospital:
|
||||||
|
obj.hospital = request.user.hospital
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||||
|
"""Limit foreign key choices to user's hospital."""
|
||||||
|
if db_field.name == 'hospital':
|
||||||
|
# Only PX admins can select any hospital
|
||||||
|
if not request.user.is_px_admin():
|
||||||
|
# Filter to user's hospital
|
||||||
|
kwargs['queryset'] = db_field.related_model.objects.filter(
|
||||||
|
id=request.user.hospital.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter department choices to user's hospital
|
||||||
|
if db_field.name == 'department':
|
||||||
|
kwargs['queryset'] = db_field.related_model.objects.filter(
|
||||||
|
hospital=request.user.hospital
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantRequiredMixin(LoginRequiredMixin):
|
||||||
|
"""
|
||||||
|
Mixin that ensures user has hospital context.
|
||||||
|
|
||||||
|
This mixin ensures:
|
||||||
|
- User is authenticated (from LoginRequiredMixin)
|
||||||
|
- User has a hospital assigned (or is PX Admin)
|
||||||
|
- Redirects PX Admins to hospital selector if no hospital selected
|
||||||
|
- Redirects other users to error page if no hospital assigned
|
||||||
|
"""
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""Check hospital context before processing request."""
|
||||||
|
response = super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# PX Admins need to select a hospital
|
||||||
|
if request.user.is_px_admin():
|
||||||
|
if not request.tenant_hospital:
|
||||||
|
return redirect('core:select_hospital')
|
||||||
|
|
||||||
|
# Other users must have a hospital assigned
|
||||||
|
elif not request.user.hospital:
|
||||||
|
return redirect('core:no_hospital_assigned')
|
||||||
|
|
||||||
|
return response
|
||||||
@ -92,12 +92,12 @@ class AuditEvent(UUIDModel, TimeStampedModel):
|
|||||||
related_name='audit_events'
|
related_name='audit_events'
|
||||||
)
|
)
|
||||||
description = models.TextField()
|
description = models.TextField()
|
||||||
|
|
||||||
# Generic foreign key to link to any model
|
# Generic foreign key to link to any model
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
object_id = models.UUIDField(null=True, blank=True)
|
object_id = models.UUIDField(null=True, blank=True)
|
||||||
content_object = GenericForeignKey('content_type', 'object_id')
|
content_object = GenericForeignKey('content_type', 'object_id')
|
||||||
|
|
||||||
# Additional metadata
|
# Additional metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
@ -141,3 +141,23 @@ class SeverityChoices(BaseChoices):
|
|||||||
MEDIUM = 'medium', 'Medium'
|
MEDIUM = 'medium', 'Medium'
|
||||||
HIGH = 'high', 'High'
|
HIGH = 'high', 'High'
|
||||||
CRITICAL = 'critical', 'Critical'
|
CRITICAL = 'critical', 'Critical'
|
||||||
|
|
||||||
|
|
||||||
|
class TenantModel(models.Model):
|
||||||
|
"""
|
||||||
|
Abstract base model for tenant-aware models.
|
||||||
|
Automatically filters by current hospital context.
|
||||||
|
"""
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
'organizations.Hospital',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='%(app_label)s_%(class)s_related',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Tenant hospital for this record"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['hospital']),
|
||||||
|
]
|
||||||
|
|||||||
1
apps/core/templatetags/__init__.py
Normal file
1
apps/core/templatetags/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Template tags for the core app
|
||||||
19
apps/core/templatetags/hospital_filters.py
Normal file
19
apps/core/templatetags/hospital_filters.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Template filters for hospital-related functionality
|
||||||
|
"""
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def get_all_hospitals():
|
||||||
|
"""
|
||||||
|
Get all hospitals for the hospital switcher dropdown.
|
||||||
|
|
||||||
|
This is used by PX Admins to quickly switch between hospitals
|
||||||
|
directly from the header without navigating to the selector page.
|
||||||
|
"""
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
return Hospital.objects.all().order_by('name', 'city')
|
||||||
@ -3,13 +3,15 @@ Core app URLs
|
|||||||
"""
|
"""
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import health_check
|
from .views import health_check, select_hospital, no_hospital_assigned
|
||||||
from . import config_views
|
from . import config_views
|
||||||
|
|
||||||
app_name = 'core'
|
app_name = 'core'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', health_check, name='health'),
|
path('', health_check, name='health'),
|
||||||
|
path('select-hospital/', select_hospital, name='select_hospital'),
|
||||||
|
path('no-hospital/', no_hospital_assigned, name='no_hospital_assigned'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Configuration URLs (separate app_name)
|
# Configuration URLs (separate app_name)
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Core views - Health check and utility views
|
Core views - Health check and utility views
|
||||||
"""
|
"""
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
from django.views.decorators.cache import never_cache
|
from django.views.decorators.cache import never_cache
|
||||||
from django.views.decorators.http import require_GET
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
||||||
|
|
||||||
@never_cache
|
@never_cache
|
||||||
@ -41,3 +43,50 @@ def health_check(request):
|
|||||||
|
|
||||||
return JsonResponse(health_status, status=status_code)
|
return JsonResponse(health_status, status=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def select_hospital(request):
|
||||||
|
"""
|
||||||
|
Hospital selection page for PX Admins.
|
||||||
|
|
||||||
|
Allows PX Admins to switch between hospitals.
|
||||||
|
Stores selected hospital in session.
|
||||||
|
"""
|
||||||
|
# Only PX Admins should access this page
|
||||||
|
if not request.user.is_px_admin():
|
||||||
|
return redirect('dashboard:dashboard')
|
||||||
|
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
hospitals = Hospital.objects.all().order_by('name')
|
||||||
|
|
||||||
|
# Handle hospital selection
|
||||||
|
if request.method == 'POST':
|
||||||
|
hospital_id = request.POST.get('hospital_id')
|
||||||
|
if hospital_id:
|
||||||
|
try:
|
||||||
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
|
request.session['selected_hospital_id'] = str(hospital.id)
|
||||||
|
# Redirect to referring page or dashboard
|
||||||
|
next_url = request.POST.get('next', request.GET.get('next', '/'))
|
||||||
|
return redirect(next_url)
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'hospitals': hospitals,
|
||||||
|
'selected_hospital_id': request.session.get('selected_hospital_id'),
|
||||||
|
'next': request.GET.get('next', '/'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'core/select_hospital.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def no_hospital_assigned(request):
|
||||||
|
"""
|
||||||
|
Error page for users without a hospital assigned.
|
||||||
|
|
||||||
|
Users without a hospital assignment cannot access the system.
|
||||||
|
"""
|
||||||
|
return render(request, 'core/no_hospital_assigned.html', status=403)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.db.models import Avg, Count, Q
|
from django.db.models import Avg, Count, Q
|
||||||
|
from django.shortcuts import redirect
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -14,7 +15,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
class CommandCenterView(LoginRequiredMixin, TemplateView):
|
class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||||
"""
|
"""
|
||||||
PX Command Center Dashboard - Real-time control panel.
|
PX Command Center Dashboard - Real-time control panel.
|
||||||
|
|
||||||
Shows:
|
Shows:
|
||||||
- Top KPI cards (complaints, actions, surveys, etc.)
|
- Top KPI cards (complaints, actions, surveys, etc.)
|
||||||
- Charts (trends, satisfaction, leaderboards)
|
- Charts (trends, satisfaction, leaderboards)
|
||||||
@ -22,11 +23,19 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|||||||
- Filters (date range, hospital, department)
|
- Filters (date range, hospital, department)
|
||||||
"""
|
"""
|
||||||
template_name = 'dashboard/command_center.html'
|
template_name = 'dashboard/command_center.html'
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""Check PX Admin has selected a hospital before processing request"""
|
||||||
|
# Check PX Admin has selected a hospital
|
||||||
|
if request.user.is_px_admin() and not request.tenant_hospital:
|
||||||
|
return redirect('core:select_hospital')
|
||||||
|
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
from apps.px_action_center.models import PXAction
|
from apps.px_action_center.models import PXAction
|
||||||
@ -35,21 +44,23 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|||||||
from apps.callcenter.models import CallCenterInteraction
|
from apps.callcenter.models import CallCenterInteraction
|
||||||
from apps.integrations.models import InboundEvent
|
from apps.integrations.models import InboundEvent
|
||||||
from apps.physicians.models import PhysicianMonthlyRating
|
from apps.physicians.models import PhysicianMonthlyRating
|
||||||
from apps.organizations.models import Physician
|
from apps.organizations.models import Staff
|
||||||
|
|
||||||
# Date filters
|
# Date filters
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
last_24h = now - timedelta(hours=24)
|
last_24h = now - timedelta(hours=24)
|
||||||
last_7d = now - timedelta(days=7)
|
last_7d = now - timedelta(days=7)
|
||||||
last_30d = now - timedelta(days=30)
|
last_30d = now - timedelta(days=30)
|
||||||
|
|
||||||
# Base querysets (filtered by user role)
|
# Base querysets (filtered by user role and tenant_hospital)
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
complaints_qs = Complaint.objects.all()
|
# PX Admins use their selected hospital from session
|
||||||
actions_qs = PXAction.objects.all()
|
hospital = self.request.tenant_hospital
|
||||||
surveys_qs = SurveyInstance.objects.all()
|
complaints_qs = Complaint.objects.filter(hospital=hospital) if hospital else Complaint.objects.none()
|
||||||
social_qs = SocialMention.objects.all()
|
actions_qs = PXAction.objects.filter(hospital=hospital) if hospital else PXAction.objects.none()
|
||||||
calls_qs = CallCenterInteraction.objects.all()
|
surveys_qs = SurveyInstance.objects.all() # Surveys can be viewed across hospitals
|
||||||
|
social_qs = SocialMention.objects.filter(hospital=hospital) if hospital else SocialMention.objects.none()
|
||||||
|
calls_qs = CallCenterInteraction.objects.filter(hospital=hospital) if hospital else CallCenterInteraction.objects.none()
|
||||||
elif user.is_hospital_admin() and user.hospital:
|
elif user.is_hospital_admin() and user.hospital:
|
||||||
complaints_qs = Complaint.objects.filter(hospital=user.hospital)
|
complaints_qs = Complaint.objects.filter(hospital=user.hospital)
|
||||||
actions_qs = PXAction.objects.filter(hospital=user.hospital)
|
actions_qs = PXAction.objects.filter(hospital=user.hospital)
|
||||||
@ -68,7 +79,7 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|||||||
surveys_qs = SurveyInstance.objects.none()
|
surveys_qs = SurveyInstance.objects.none()
|
||||||
social_qs = SocialMention.objects.none()
|
social_qs = SocialMention.objects.none()
|
||||||
calls_qs = CallCenterInteraction.objects.none()
|
calls_qs = CallCenterInteraction.objects.none()
|
||||||
|
|
||||||
# Top KPI Stats
|
# Top KPI Stats
|
||||||
context['stats'] = [
|
context['stats'] = [
|
||||||
{
|
{
|
||||||
@ -120,54 +131,58 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|||||||
'color': 'success'
|
'color': 'success'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Latest high severity complaints
|
# Latest high severity complaints
|
||||||
context['latest_complaints'] = complaints_qs.filter(
|
context['latest_complaints'] = complaints_qs.filter(
|
||||||
severity__in=['high', 'critical']
|
severity__in=['high', 'critical']
|
||||||
).select_related('patient', 'hospital', 'department').order_by('-created_at')[:5]
|
).select_related('patient', 'hospital', 'department').order_by('-created_at')[:5]
|
||||||
|
|
||||||
# Latest escalated actions
|
# Latest escalated actions
|
||||||
context['latest_actions'] = actions_qs.filter(
|
context['latest_actions'] = actions_qs.filter(
|
||||||
escalation_level__gt=0
|
escalation_level__gt=0
|
||||||
).select_related('hospital', 'assigned_to').order_by('-escalated_at')[:5]
|
).select_related('hospital', 'assigned_to').order_by('-escalated_at')[:5]
|
||||||
|
|
||||||
# Latest integration events
|
# Latest integration events
|
||||||
context['latest_events'] = InboundEvent.objects.filter(
|
context['latest_events'] = InboundEvent.objects.filter(
|
||||||
status='processed'
|
status='processed'
|
||||||
).select_related().order_by('-processed_at')[:10]
|
).select_related().order_by('-processed_at')[:10]
|
||||||
|
|
||||||
# Physician ratings data
|
# Staff ratings data
|
||||||
current_month_ratings = PhysicianMonthlyRating.objects.filter(
|
current_month_ratings = PhysicianMonthlyRating.objects.filter(
|
||||||
year=now.year,
|
year=now.year,
|
||||||
month=now.month
|
month=now.month
|
||||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||||
|
|
||||||
# Filter by user role
|
# Filter by user role
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
current_month_ratings = current_month_ratings.filter(physician__hospital=user.hospital)
|
current_month_ratings = current_month_ratings.filter(staff__hospital=user.hospital)
|
||||||
elif user.is_department_manager() and user.department:
|
elif user.is_department_manager() and user.department:
|
||||||
current_month_ratings = current_month_ratings.filter(physician__department=user.department)
|
current_month_ratings = current_month_ratings.filter(staff__department=user.department)
|
||||||
|
|
||||||
# Top 5 physicians this month
|
# Top 5 staff this month
|
||||||
context['top_physicians'] = current_month_ratings.order_by('-average_rating')[:5]
|
context['top_physicians'] = current_month_ratings.order_by('-average_rating')[:5]
|
||||||
|
|
||||||
# Physician stats
|
# Staff stats
|
||||||
physician_stats = current_month_ratings.aggregate(
|
physician_stats = current_month_ratings.aggregate(
|
||||||
total_physicians=Count('id'),
|
total_physicians=Count('id'),
|
||||||
avg_rating=Avg('average_rating'),
|
avg_rating=Avg('average_rating'),
|
||||||
total_surveys=Count('total_surveys')
|
total_surveys=Count('total_surveys')
|
||||||
)
|
)
|
||||||
context['physician_stats'] = physician_stats
|
context['physician_stats'] = physician_stats
|
||||||
|
|
||||||
# Chart data (simplified for now)
|
# Chart data (simplified for now)
|
||||||
import json
|
import json
|
||||||
context['chart_data'] = {
|
context['chart_data'] = {
|
||||||
'complaints_trend': json.dumps(self.get_complaints_trend(complaints_qs, last_30d)),
|
'complaints_trend': json.dumps(self.get_complaints_trend(complaints_qs, last_30d)),
|
||||||
'survey_satisfaction': self.get_survey_satisfaction(surveys_qs, last_30d),
|
'survey_satisfaction': self.get_survey_satisfaction(surveys_qs, last_30d),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add hospital context
|
||||||
|
context['current_hospital'] = self.request.tenant_hospital
|
||||||
|
context['is_px_admin'] = user.is_px_admin()
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_complaints_trend(self, queryset, start_date):
|
def get_complaints_trend(self, queryset, start_date):
|
||||||
"""Get complaints trend data for chart"""
|
"""Get complaints trend data for chart"""
|
||||||
# Group by day for last 30 days
|
# Group by day for last 30 days
|
||||||
@ -182,7 +197,7 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|||||||
'count': count
|
'count': count
|
||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_survey_satisfaction(self, queryset, start_date):
|
def get_survey_satisfaction(self, queryset, start_date):
|
||||||
"""Get survey satisfaction averages"""
|
"""Get survey satisfaction averages"""
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
|
|||||||
@ -3,14 +3,14 @@ Feedback forms - Forms for feedback management
|
|||||||
"""
|
"""
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from apps.organizations.models import Department, Hospital, Patient, Physician
|
from apps.organizations.models import Department, Hospital, Patient, Staff
|
||||||
|
|
||||||
from .models import Feedback, FeedbackResponse, FeedbackStatus, FeedbackType, FeedbackCategory
|
from .models import Feedback, FeedbackResponse, FeedbackStatus, FeedbackType, FeedbackCategory
|
||||||
|
|
||||||
|
|
||||||
class FeedbackForm(forms.ModelForm):
|
class FeedbackForm(forms.ModelForm):
|
||||||
"""Form for creating and editing feedback"""
|
"""Form for creating and editing feedback"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Feedback
|
model = Feedback
|
||||||
fields = [
|
fields = [
|
||||||
@ -21,7 +21,7 @@ class FeedbackForm(forms.ModelForm):
|
|||||||
'contact_phone',
|
'contact_phone',
|
||||||
'hospital',
|
'hospital',
|
||||||
'department',
|
'department',
|
||||||
'physician',
|
'staff',
|
||||||
'feedback_type',
|
'feedback_type',
|
||||||
'title',
|
'title',
|
||||||
'message',
|
'message',
|
||||||
@ -59,7 +59,7 @@ class FeedbackForm(forms.ModelForm):
|
|||||||
'department': forms.Select(attrs={
|
'department': forms.Select(attrs={
|
||||||
'class': 'form-select'
|
'class': 'form-select'
|
||||||
}),
|
}),
|
||||||
'physician': forms.Select(attrs={
|
'staff': forms.Select(attrs={
|
||||||
'class': 'form-select'
|
'class': 'form-select'
|
||||||
}),
|
}),
|
||||||
'feedback_type': forms.Select(attrs={
|
'feedback_type': forms.Select(attrs={
|
||||||
@ -99,11 +99,11 @@ class FeedbackForm(forms.ModelForm):
|
|||||||
'placeholder': 'Enter encounter ID (optional)'
|
'placeholder': 'Enter encounter ID (optional)'
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
user = kwargs.pop('user', None)
|
user = kwargs.pop('user', None)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Filter hospitals based on user permissions
|
# Filter hospitals based on user permissions
|
||||||
if user:
|
if user:
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
@ -113,35 +113,35 @@ class FeedbackForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.fields['hospital'].queryset = Hospital.objects.filter(status='active')
|
self.fields['hospital'].queryset = Hospital.objects.filter(status='active')
|
||||||
|
|
||||||
# Set initial hospital if user has one
|
# Set initial hospital if user has one
|
||||||
if user and user.hospital and not self.instance.pk:
|
if user and user.hospital and not self.instance.pk:
|
||||||
self.fields['hospital'].initial = user.hospital
|
self.fields['hospital'].initial = user.hospital
|
||||||
|
|
||||||
# Filter departments and physicians based on selected hospital
|
# Filter departments and physicians based on selected hospital
|
||||||
if self.instance.pk and hasattr(self.instance, 'hospital') and self.instance.hospital_id:
|
if self.instance.pk and hasattr(self.instance, 'hospital') and self.instance.hospital_id:
|
||||||
self.fields['department'].queryset = Department.objects.filter(
|
self.fields['department'].queryset = Department.objects.filter(
|
||||||
hospital=self.instance.hospital,
|
hospital=self.instance.hospital,
|
||||||
status='active'
|
status='active'
|
||||||
)
|
)
|
||||||
self.fields['physician'].queryset = Physician.objects.filter(
|
self.fields['staff'].queryset = Staff.objects.filter(
|
||||||
hospital=self.instance.hospital,
|
hospital=self.instance.hospital,
|
||||||
status='active'
|
status='active'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.fields['department'].queryset = Department.objects.none()
|
self.fields['department'].queryset = Department.objects.none()
|
||||||
self.fields['physician'].queryset = Physician.objects.none()
|
self.fields['staff'].queryset = Staff.objects.none()
|
||||||
|
|
||||||
# Make patient optional if anonymous
|
# Make patient optional if anonymous
|
||||||
if self.data.get('is_anonymous'):
|
if self.data.get('is_anonymous'):
|
||||||
self.fields['patient'].required = False
|
self.fields['patient'].required = False
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
is_anonymous = cleaned_data.get('is_anonymous')
|
is_anonymous = cleaned_data.get('is_anonymous')
|
||||||
patient = cleaned_data.get('patient')
|
patient = cleaned_data.get('patient')
|
||||||
contact_name = cleaned_data.get('contact_name')
|
contact_name = cleaned_data.get('contact_name')
|
||||||
|
|
||||||
# Validate anonymous feedback
|
# Validate anonymous feedback
|
||||||
if is_anonymous:
|
if is_anonymous:
|
||||||
if not contact_name:
|
if not contact_name:
|
||||||
@ -153,18 +153,18 @@ class FeedbackForm(forms.ModelForm):
|
|||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
"Please select a patient or mark as anonymous."
|
"Please select a patient or mark as anonymous."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate rating
|
# Validate rating
|
||||||
rating = cleaned_data.get('rating')
|
rating = cleaned_data.get('rating')
|
||||||
if rating is not None and (rating < 1 or rating > 5):
|
if rating is not None and (rating < 1 or rating > 5):
|
||||||
raise forms.ValidationError("Rating must be between 1 and 5.")
|
raise forms.ValidationError("Rating must be between 1 and 5.")
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class FeedbackResponseForm(forms.ModelForm):
|
class FeedbackResponseForm(forms.ModelForm):
|
||||||
"""Form for adding responses to feedback"""
|
"""Form for adding responses to feedback"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FeedbackResponse
|
model = FeedbackResponse
|
||||||
fields = ['response_type', 'message', 'is_internal']
|
fields = ['response_type', 'message', 'is_internal']
|
||||||
@ -187,7 +187,7 @@ class FeedbackResponseForm(forms.ModelForm):
|
|||||||
|
|
||||||
class FeedbackFilterForm(forms.Form):
|
class FeedbackFilterForm(forms.Form):
|
||||||
"""Form for filtering feedback list"""
|
"""Form for filtering feedback list"""
|
||||||
|
|
||||||
search = forms.CharField(
|
search = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.TextInput(attrs={
|
widget=forms.TextInput(attrs={
|
||||||
@ -195,25 +195,25 @@ class FeedbackFilterForm(forms.Form):
|
|||||||
'placeholder': 'Search by title, message, patient...'
|
'placeholder': 'Search by title, message, patient...'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
feedback_type = forms.ChoiceField(
|
feedback_type = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=[('', 'All Types')] + list(FeedbackType.choices),
|
choices=[('', 'All Types')] + list(FeedbackType.choices),
|
||||||
widget=forms.Select(attrs={'class': 'form-select'})
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
)
|
)
|
||||||
|
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=[('', 'All Statuses')] + list(FeedbackStatus.choices),
|
choices=[('', 'All Statuses')] + list(FeedbackStatus.choices),
|
||||||
widget=forms.Select(attrs={'class': 'form-select'})
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
)
|
)
|
||||||
|
|
||||||
category = forms.ChoiceField(
|
category = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=[('', 'All Categories')] + list(FeedbackCategory.choices),
|
choices=[('', 'All Categories')] + list(FeedbackCategory.choices),
|
||||||
widget=forms.Select(attrs={'class': 'form-select'})
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
)
|
)
|
||||||
|
|
||||||
sentiment = forms.ChoiceField(
|
sentiment = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=[
|
choices=[
|
||||||
@ -224,7 +224,7 @@ class FeedbackFilterForm(forms.Form):
|
|||||||
],
|
],
|
||||||
widget=forms.Select(attrs={'class': 'form-select'})
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
)
|
)
|
||||||
|
|
||||||
priority = forms.ChoiceField(
|
priority = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=[
|
choices=[
|
||||||
@ -236,21 +236,21 @@ class FeedbackFilterForm(forms.Form):
|
|||||||
],
|
],
|
||||||
widget=forms.Select(attrs={'class': 'form-select'})
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
)
|
)
|
||||||
|
|
||||||
hospital = forms.ModelChoiceField(
|
hospital = forms.ModelChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=Hospital.objects.filter(status='active'),
|
queryset=Hospital.objects.filter(status='active'),
|
||||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||||
empty_label='All Hospitals'
|
empty_label='All Hospitals'
|
||||||
)
|
)
|
||||||
|
|
||||||
department = forms.ModelChoiceField(
|
department = forms.ModelChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=Department.objects.filter(status='active'),
|
queryset=Department.objects.filter(status='active'),
|
||||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||||
empty_label='All Departments'
|
empty_label='All Departments'
|
||||||
)
|
)
|
||||||
|
|
||||||
rating_min = forms.IntegerField(
|
rating_min = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
min_value=1,
|
min_value=1,
|
||||||
@ -260,7 +260,7 @@ class FeedbackFilterForm(forms.Form):
|
|||||||
'placeholder': 'Min rating'
|
'placeholder': 'Min rating'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
rating_max = forms.IntegerField(
|
rating_max = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
min_value=1,
|
min_value=1,
|
||||||
@ -270,7 +270,7 @@ class FeedbackFilterForm(forms.Form):
|
|||||||
'placeholder': 'Max rating'
|
'placeholder': 'Max rating'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
date_from = forms.DateField(
|
date_from = forms.DateField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.DateInput(attrs={
|
widget=forms.DateInput(attrs={
|
||||||
@ -278,7 +278,7 @@ class FeedbackFilterForm(forms.Form):
|
|||||||
'type': 'date'
|
'type': 'date'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
date_to = forms.DateField(
|
date_to = forms.DateField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.DateInput(attrs={
|
widget=forms.DateInput(attrs={
|
||||||
@ -286,12 +286,12 @@ class FeedbackFilterForm(forms.Form):
|
|||||||
'type': 'date'
|
'type': 'date'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
is_featured = forms.BooleanField(
|
is_featured = forms.BooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
)
|
)
|
||||||
|
|
||||||
requires_follow_up = forms.BooleanField(
|
requires_follow_up = forms.BooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
@ -300,7 +300,7 @@ class FeedbackFilterForm(forms.Form):
|
|||||||
|
|
||||||
class FeedbackStatusChangeForm(forms.Form):
|
class FeedbackStatusChangeForm(forms.Form):
|
||||||
"""Form for changing feedback status"""
|
"""Form for changing feedback status"""
|
||||||
|
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=FeedbackStatus.choices,
|
choices=FeedbackStatus.choices,
|
||||||
widget=forms.Select(attrs={
|
widget=forms.Select(attrs={
|
||||||
@ -308,7 +308,7 @@ class FeedbackStatusChangeForm(forms.Form):
|
|||||||
'required': True
|
'required': True
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
note = forms.CharField(
|
note = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Textarea(attrs={
|
widget=forms.Textarea(attrs={
|
||||||
@ -321,14 +321,14 @@ class FeedbackStatusChangeForm(forms.Form):
|
|||||||
|
|
||||||
class FeedbackAssignForm(forms.Form):
|
class FeedbackAssignForm(forms.Form):
|
||||||
"""Form for assigning feedback to a user"""
|
"""Form for assigning feedback to a user"""
|
||||||
|
|
||||||
user_id = forms.UUIDField(
|
user_id = forms.UUIDField(
|
||||||
widget=forms.Select(attrs={
|
widget=forms.Select(attrs={
|
||||||
'class': 'form-select',
|
'class': 'form-select',
|
||||||
'required': True
|
'required': True
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
note = forms.CharField(
|
note = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Textarea(attrs={
|
widget=forms.Textarea(attrs={
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-24 10:22
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
@ -16,6 +16,39 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
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(
|
migrations.CreateModel(
|
||||||
name='Feedback',
|
name='Feedback',
|
||||||
fields=[
|
fields=[
|
||||||
@ -27,7 +60,7 @@ class Migration(migrations.Migration):
|
|||||||
('contact_email', models.EmailField(blank=True, max_length=254)),
|
('contact_email', models.EmailField(blank=True, max_length=254)),
|
||||||
('contact_phone', models.CharField(blank=True, max_length=20)),
|
('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)),
|
('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')], db_index=True, default='general', max_length=20)),
|
('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)),
|
('title', models.CharField(max_length=500)),
|
||||||
('message', models.TextField(help_text='Feedback message')),
|
('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)),
|
('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)),
|
||||||
@ -55,73 +88,10 @@ class Migration(migrations.Migration):
|
|||||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.department')),
|
('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')),
|
('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')),
|
('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')),
|
||||||
('physician', models.ForeignKey(blank=True, help_text='Physician being mentioned in feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.physician')),
|
|
||||||
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_feedbacks', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name_plural': 'Feedback',
|
'verbose_name_plural': 'Feedback',
|
||||||
'ordering': ['-created_at'],
|
'ordering': ['-created_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
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)),
|
|
||||||
('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='feedback.feedback')),
|
|
||||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_attachments', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
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)),
|
|
||||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_responses', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='feedback.feedback')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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,25 +0,0 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-28 16:51
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('feedback', '0001_initial'),
|
|
||||||
('surveys', '0003_add_survey_linkage'),
|
|
||||||
]
|
|
||||||
|
|
||||||
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.AlterField(
|
|
||||||
model_name='feedback',
|
|
||||||
name='feedback_type',
|
|
||||||
field=models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry'), ('satisfaction_check', 'Satisfaction Check')], db_index=True, default='general', max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
79
apps/feedback/migrations/0002_initial.py
Normal file
79
apps/feedback/migrations/0002_initial.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
|
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'),
|
||||||
|
('organizations', '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),
|
||||||
|
),
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -55,7 +55,7 @@ class SentimentChoices(models.TextChoices):
|
|||||||
class Feedback(UUIDModel, TimeStampedModel):
|
class Feedback(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Feedback model for patient feedback, compliments, and suggestions.
|
Feedback model for patient feedback, compliments, and suggestions.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. SUBMITTED - Feedback received
|
1. SUBMITTED - Feedback received
|
||||||
2. REVIEWED - Being reviewed by staff
|
2. REVIEWED - Being reviewed by staff
|
||||||
@ -71,20 +71,20 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Patient who provided feedback (optional for anonymous feedback)"
|
help_text="Patient who provided feedback (optional for anonymous feedback)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Anonymous feedback support
|
# Anonymous feedback support
|
||||||
is_anonymous = models.BooleanField(default=False)
|
is_anonymous = models.BooleanField(default=False)
|
||||||
contact_name = models.CharField(max_length=200, blank=True)
|
contact_name = models.CharField(max_length=200, blank=True)
|
||||||
contact_email = models.EmailField(blank=True)
|
contact_email = models.EmailField(blank=True)
|
||||||
contact_phone = models.CharField(max_length=20, blank=True)
|
contact_phone = models.CharField(max_length=20, blank=True)
|
||||||
|
|
||||||
encounter_id = models.CharField(
|
encounter_id = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
blank=True,
|
blank=True,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Related encounter ID if applicable"
|
help_text="Related encounter ID if applicable"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Survey linkage (for satisfaction checks after negative surveys)
|
# Survey linkage (for satisfaction checks after negative surveys)
|
||||||
related_survey = models.ForeignKey(
|
related_survey = models.ForeignKey(
|
||||||
'surveys.SurveyInstance',
|
'surveys.SurveyInstance',
|
||||||
@ -94,7 +94,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
related_name='follow_up_feedbacks',
|
related_name='follow_up_feedbacks',
|
||||||
help_text="Survey that triggered this satisfaction check feedback"
|
help_text="Survey that triggered this satisfaction check feedback"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Organization
|
# Organization
|
||||||
hospital = models.ForeignKey(
|
hospital = models.ForeignKey(
|
||||||
'organizations.Hospital',
|
'organizations.Hospital',
|
||||||
@ -108,15 +108,15 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='feedbacks'
|
related_name='feedbacks'
|
||||||
)
|
)
|
||||||
physician = models.ForeignKey(
|
staff = models.ForeignKey(
|
||||||
'organizations.Physician',
|
'organizations.Staff',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='feedbacks',
|
related_name='feedbacks',
|
||||||
help_text="Physician being mentioned in feedback"
|
help_text="Staff member being mentioned in feedback"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Feedback details
|
# Feedback details
|
||||||
feedback_type = models.CharField(
|
feedback_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -124,10 +124,10 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
default=FeedbackType.GENERAL,
|
default=FeedbackType.GENERAL,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
message = models.TextField(help_text="Feedback message")
|
message = models.TextField(help_text="Feedback message")
|
||||||
|
|
||||||
# Classification
|
# Classification
|
||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -135,14 +135,14 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
subcategory = models.CharField(max_length=100, blank=True)
|
subcategory = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
# Rating (1-5 stars)
|
# Rating (1-5 stars)
|
||||||
rating = models.IntegerField(
|
rating = models.IntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Rating from 1 to 5 stars"
|
help_text="Rating from 1 to 5 stars"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Priority
|
# Priority
|
||||||
priority = models.CharField(
|
priority = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -150,7 +150,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
default=PriorityChoices.MEDIUM,
|
default=PriorityChoices.MEDIUM,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sentiment analysis
|
# Sentiment analysis
|
||||||
sentiment = models.CharField(
|
sentiment = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -164,7 +164,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Sentiment score from -1 (negative) to 1 (positive)"
|
help_text="Sentiment score from -1 (negative) to 1 (positive)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Status and workflow
|
# Status and workflow
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -172,7 +172,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
default=FeedbackStatus.SUBMITTED,
|
default=FeedbackStatus.SUBMITTED,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assignment
|
# Assignment
|
||||||
assigned_to = models.ForeignKey(
|
assigned_to = models.ForeignKey(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
@ -182,7 +182,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
related_name='assigned_feedbacks'
|
related_name='assigned_feedbacks'
|
||||||
)
|
)
|
||||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
# Review tracking
|
# Review tracking
|
||||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||||
reviewed_by = models.ForeignKey(
|
reviewed_by = models.ForeignKey(
|
||||||
@ -192,7 +192,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='reviewed_feedbacks'
|
related_name='reviewed_feedbacks'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Acknowledgment
|
# Acknowledgment
|
||||||
acknowledged_at = models.DateTimeField(null=True, blank=True)
|
acknowledged_at = models.DateTimeField(null=True, blank=True)
|
||||||
acknowledged_by = models.ForeignKey(
|
acknowledged_by = models.ForeignKey(
|
||||||
@ -202,7 +202,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='acknowledged_feedbacks'
|
related_name='acknowledged_feedbacks'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Closure
|
# Closure
|
||||||
closed_at = models.DateTimeField(null=True, blank=True)
|
closed_at = models.DateTimeField(null=True, blank=True)
|
||||||
closed_by = models.ForeignKey(
|
closed_by = models.ForeignKey(
|
||||||
@ -212,7 +212,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='closed_feedbacks'
|
related_name='closed_feedbacks'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Flags
|
# Flags
|
||||||
is_featured = models.BooleanField(
|
is_featured = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
@ -223,7 +223,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
help_text="Make this feedback public"
|
help_text="Make this feedback public"
|
||||||
)
|
)
|
||||||
requires_follow_up = models.BooleanField(default=False)
|
requires_follow_up = models.BooleanField(default=False)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
source = models.CharField(
|
source = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -231,7 +231,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
help_text="Source of feedback (web, mobile, kiosk, etc.)"
|
help_text="Source of feedback (web, mobile, kiosk, etc.)"
|
||||||
)
|
)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
# Soft delete
|
# Soft delete
|
||||||
is_deleted = models.BooleanField(default=False, db_index=True)
|
is_deleted = models.BooleanField(default=False, db_index=True)
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
@ -242,7 +242,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='deleted_feedbacks'
|
related_name='deleted_feedbacks'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -253,18 +253,18 @@ class Feedback(UUIDModel, TimeStampedModel):
|
|||||||
models.Index(fields=['is_deleted', '-created_at']),
|
models.Index(fields=['is_deleted', '-created_at']),
|
||||||
]
|
]
|
||||||
verbose_name_plural = 'Feedback'
|
verbose_name_plural = 'Feedback'
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.patient:
|
if self.patient:
|
||||||
return f"{self.title} - {self.patient.get_full_name()} ({self.feedback_type})"
|
return f"{self.title} - {self.patient.get_full_name()} ({self.feedback_type})"
|
||||||
return f"{self.title} - Anonymous ({self.feedback_type})"
|
return f"{self.title} - Anonymous ({self.feedback_type})"
|
||||||
|
|
||||||
def get_contact_name(self):
|
def get_contact_name(self):
|
||||||
"""Get contact name (patient or anonymous)"""
|
"""Get contact name (patient or anonymous)"""
|
||||||
if self.patient:
|
if self.patient:
|
||||||
return self.patient.get_full_name()
|
return self.patient.get_full_name()
|
||||||
return self.contact_name or "Anonymous"
|
return self.contact_name or "Anonymous"
|
||||||
|
|
||||||
def soft_delete(self, user=None):
|
def soft_delete(self, user=None):
|
||||||
"""Soft delete feedback"""
|
"""Soft delete feedback"""
|
||||||
self.is_deleted = True
|
self.is_deleted = True
|
||||||
@ -280,12 +280,12 @@ class FeedbackAttachment(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='attachments'
|
related_name='attachments'
|
||||||
)
|
)
|
||||||
|
|
||||||
file = models.FileField(upload_to='feedback/%Y/%m/%d/')
|
file = models.FileField(upload_to='feedback/%Y/%m/%d/')
|
||||||
filename = models.CharField(max_length=500)
|
filename = models.CharField(max_length=500)
|
||||||
file_type = models.CharField(max_length=100, blank=True)
|
file_type = models.CharField(max_length=100, blank=True)
|
||||||
file_size = models.IntegerField(help_text="File size in bytes")
|
file_size = models.IntegerField(help_text="File size in bytes")
|
||||||
|
|
||||||
uploaded_by = models.ForeignKey(
|
uploaded_by = models.ForeignKey(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -293,12 +293,12 @@ class FeedbackAttachment(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='feedback_attachments'
|
related_name='feedback_attachments'
|
||||||
)
|
)
|
||||||
|
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.feedback} - {self.filename}"
|
return f"{self.feedback} - {self.filename}"
|
||||||
|
|
||||||
@ -306,7 +306,7 @@ class FeedbackAttachment(UUIDModel, TimeStampedModel):
|
|||||||
class FeedbackResponse(UUIDModel, TimeStampedModel):
|
class FeedbackResponse(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Feedback response/timeline entry.
|
Feedback response/timeline entry.
|
||||||
|
|
||||||
Tracks all responses, status changes, and communications.
|
Tracks all responses, status changes, and communications.
|
||||||
"""
|
"""
|
||||||
feedback = models.ForeignKey(
|
feedback = models.ForeignKey(
|
||||||
@ -314,7 +314,7 @@ class FeedbackResponse(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='responses'
|
related_name='responses'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Response details
|
# Response details
|
||||||
response_type = models.CharField(
|
response_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -327,9 +327,9 @@ class FeedbackResponse(UUIDModel, TimeStampedModel):
|
|||||||
],
|
],
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
message = models.TextField()
|
message = models.TextField()
|
||||||
|
|
||||||
# User who made the response
|
# User who made the response
|
||||||
created_by = models.ForeignKey(
|
created_by = models.ForeignKey(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
@ -337,25 +337,25 @@ class FeedbackResponse(UUIDModel, TimeStampedModel):
|
|||||||
null=True,
|
null=True,
|
||||||
related_name='feedback_responses'
|
related_name='feedback_responses'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Status change tracking
|
# Status change tracking
|
||||||
old_status = models.CharField(max_length=20, blank=True)
|
old_status = models.CharField(max_length=20, blank=True)
|
||||||
new_status = models.CharField(max_length=20, blank=True)
|
new_status = models.CharField(max_length=20, blank=True)
|
||||||
|
|
||||||
# Visibility
|
# Visibility
|
||||||
is_internal = models.BooleanField(
|
is_internal = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text="Internal note (not visible to patient)"
|
help_text="Internal note (not visible to patient)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['feedback', '-created_at']),
|
models.Index(fields=['feedback', '-created_at']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.feedback} - {self.response_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
return f"{self.feedback} - {self.response_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from django.views.decorators.http import require_http_methods
|
|||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
from apps.core.services import AuditService
|
from apps.core.services import AuditService
|
||||||
from apps.organizations.models import Department, Hospital, Patient, Physician
|
from apps.organizations.models import Department, Hospital, Patient, Staff
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Feedback,
|
Feedback,
|
||||||
@ -32,7 +32,7 @@ from .forms import (
|
|||||||
def feedback_list(request):
|
def feedback_list(request):
|
||||||
"""
|
"""
|
||||||
Feedback list view with advanced filters and pagination.
|
Feedback list view with advanced filters and pagination.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Server-side pagination
|
- Server-side pagination
|
||||||
- Advanced filters (status, type, sentiment, category, hospital, etc.)
|
- Advanced filters (status, type, sentiment, category, hospital, etc.)
|
||||||
@ -42,10 +42,10 @@ def feedback_list(request):
|
|||||||
"""
|
"""
|
||||||
# Base queryset with optimizations
|
# Base queryset with optimizations
|
||||||
queryset = Feedback.objects.select_related(
|
queryset = Feedback.objects.select_related(
|
||||||
'patient', 'hospital', 'department', 'physician',
|
'patient', 'hospital', 'department', 'staff',
|
||||||
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
|
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
|
||||||
).filter(is_deleted=False)
|
).filter(is_deleted=False)
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
@ -58,60 +58,60 @@ def feedback_list(request):
|
|||||||
queryset = queryset.filter(hospital=user.hospital)
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
|
|
||||||
# Apply filters from request
|
# Apply filters from request
|
||||||
feedback_type_filter = request.GET.get('feedback_type')
|
feedback_type_filter = request.GET.get('feedback_type')
|
||||||
if feedback_type_filter:
|
if feedback_type_filter:
|
||||||
queryset = queryset.filter(feedback_type=feedback_type_filter)
|
queryset = queryset.filter(feedback_type=feedback_type_filter)
|
||||||
|
|
||||||
status_filter = request.GET.get('status')
|
status_filter = request.GET.get('status')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
category_filter = request.GET.get('category')
|
category_filter = request.GET.get('category')
|
||||||
if category_filter:
|
if category_filter:
|
||||||
queryset = queryset.filter(category=category_filter)
|
queryset = queryset.filter(category=category_filter)
|
||||||
|
|
||||||
sentiment_filter = request.GET.get('sentiment')
|
sentiment_filter = request.GET.get('sentiment')
|
||||||
if sentiment_filter:
|
if sentiment_filter:
|
||||||
queryset = queryset.filter(sentiment=sentiment_filter)
|
queryset = queryset.filter(sentiment=sentiment_filter)
|
||||||
|
|
||||||
priority_filter = request.GET.get('priority')
|
priority_filter = request.GET.get('priority')
|
||||||
if priority_filter:
|
if priority_filter:
|
||||||
queryset = queryset.filter(priority=priority_filter)
|
queryset = queryset.filter(priority=priority_filter)
|
||||||
|
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
department_filter = request.GET.get('department')
|
department_filter = request.GET.get('department')
|
||||||
if department_filter:
|
if department_filter:
|
||||||
queryset = queryset.filter(department_id=department_filter)
|
queryset = queryset.filter(department_id=department_filter)
|
||||||
|
|
||||||
physician_filter = request.GET.get('physician')
|
staff_filter = request.GET.get('staff')
|
||||||
if physician_filter:
|
if staff_filter:
|
||||||
queryset = queryset.filter(physician_id=physician_filter)
|
queryset = queryset.filter(staff_id=staff_filter)
|
||||||
|
|
||||||
assigned_to_filter = request.GET.get('assigned_to')
|
assigned_to_filter = request.GET.get('assigned_to')
|
||||||
if assigned_to_filter:
|
if assigned_to_filter:
|
||||||
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
|
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
|
||||||
|
|
||||||
rating_min = request.GET.get('rating_min')
|
rating_min = request.GET.get('rating_min')
|
||||||
if rating_min:
|
if rating_min:
|
||||||
queryset = queryset.filter(rating__gte=rating_min)
|
queryset = queryset.filter(rating__gte=rating_min)
|
||||||
|
|
||||||
rating_max = request.GET.get('rating_max')
|
rating_max = request.GET.get('rating_max')
|
||||||
if rating_max:
|
if rating_max:
|
||||||
queryset = queryset.filter(rating__lte=rating_max)
|
queryset = queryset.filter(rating__lte=rating_max)
|
||||||
|
|
||||||
is_featured = request.GET.get('is_featured')
|
is_featured = request.GET.get('is_featured')
|
||||||
if is_featured == 'true':
|
if is_featured == 'true':
|
||||||
queryset = queryset.filter(is_featured=True)
|
queryset = queryset.filter(is_featured=True)
|
||||||
|
|
||||||
requires_follow_up = request.GET.get('requires_follow_up')
|
requires_follow_up = request.GET.get('requires_follow_up')
|
||||||
if requires_follow_up == 'true':
|
if requires_follow_up == 'true':
|
||||||
queryset = queryset.filter(requires_follow_up=True)
|
queryset = queryset.filter(requires_follow_up=True)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
@ -123,40 +123,40 @@ def feedback_list(request):
|
|||||||
Q(patient__last_name__icontains=search_query) |
|
Q(patient__last_name__icontains=search_query) |
|
||||||
Q(contact_name__icontains=search_query)
|
Q(contact_name__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Date range filters
|
# Date range filters
|
||||||
date_from = request.GET.get('date_from')
|
date_from = request.GET.get('date_from')
|
||||||
if date_from:
|
if date_from:
|
||||||
queryset = queryset.filter(created_at__gte=date_from)
|
queryset = queryset.filter(created_at__gte=date_from)
|
||||||
|
|
||||||
date_to = request.GET.get('date_to')
|
date_to = request.GET.get('date_to')
|
||||||
if date_to:
|
if date_to:
|
||||||
queryset = queryset.filter(created_at__lte=date_to)
|
queryset = queryset.filter(created_at__lte=date_to)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
order_by = request.GET.get('order_by', '-created_at')
|
order_by = request.GET.get('order_by', '-created_at')
|
||||||
queryset = queryset.order_by(order_by)
|
queryset = queryset.order_by(order_by)
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
departments = Department.objects.filter(status='active')
|
departments = Department.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
departments = departments.filter(hospital=user.hospital)
|
departments = departments.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Get assignable users
|
# Get assignable users
|
||||||
assignable_users = User.objects.filter(is_active=True)
|
assignable_users = User.objects.filter(is_active=True)
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
assignable_users = assignable_users.filter(hospital=user.hospital)
|
assignable_users = assignable_users.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
stats = {
|
stats = {
|
||||||
'total': queryset.count(),
|
'total': queryset.count(),
|
||||||
@ -169,7 +169,7 @@ def feedback_list(request):
|
|||||||
'positive': queryset.filter(sentiment='positive').count(),
|
'positive': queryset.filter(sentiment='positive').count(),
|
||||||
'negative': queryset.filter(sentiment='negative').count(),
|
'negative': queryset.filter(sentiment='negative').count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'feedbacks': page_obj.object_list,
|
'feedbacks': page_obj.object_list,
|
||||||
@ -182,7 +182,7 @@ def feedback_list(request):
|
|||||||
'category_choices': FeedbackCategory.choices,
|
'category_choices': FeedbackCategory.choices,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'feedback/feedback_list.html', context)
|
return render(request, 'feedback/feedback_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -190,7 +190,7 @@ def feedback_list(request):
|
|||||||
def feedback_detail(request, pk):
|
def feedback_detail(request, pk):
|
||||||
"""
|
"""
|
||||||
Feedback detail view with timeline, attachments, and actions.
|
Feedback detail view with timeline, attachments, and actions.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Full feedback details
|
- Full feedback details
|
||||||
- Timeline of all responses
|
- Timeline of all responses
|
||||||
@ -199,7 +199,7 @@ def feedback_detail(request, pk):
|
|||||||
"""
|
"""
|
||||||
feedback = get_object_or_404(
|
feedback = get_object_or_404(
|
||||||
Feedback.objects.select_related(
|
Feedback.objects.select_related(
|
||||||
'patient', 'hospital', 'department', 'physician',
|
'patient', 'hospital', 'department', 'staff',
|
||||||
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
|
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'attachments',
|
'attachments',
|
||||||
@ -208,7 +208,7 @@ def feedback_detail(request, pk):
|
|||||||
pk=pk,
|
pk=pk,
|
||||||
is_deleted=False
|
is_deleted=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check access
|
# Check access
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin():
|
if not user.is_px_admin():
|
||||||
@ -221,18 +221,18 @@ def feedback_detail(request, pk):
|
|||||||
elif user.hospital and feedback.hospital != user.hospital:
|
elif user.hospital and feedback.hospital != user.hospital:
|
||||||
messages.error(request, "You don't have permission to view this feedback.")
|
messages.error(request, "You don't have permission to view this feedback.")
|
||||||
return redirect('feedback:feedback_list')
|
return redirect('feedback:feedback_list')
|
||||||
|
|
||||||
# Get timeline (responses)
|
# Get timeline (responses)
|
||||||
timeline = feedback.responses.all().order_by('-created_at')
|
timeline = feedback.responses.all().order_by('-created_at')
|
||||||
|
|
||||||
# Get attachments
|
# Get attachments
|
||||||
attachments = feedback.attachments.all().order_by('-created_at')
|
attachments = feedback.attachments.all().order_by('-created_at')
|
||||||
|
|
||||||
# Get assignable users
|
# Get assignable users
|
||||||
assignable_users = User.objects.filter(is_active=True)
|
assignable_users = User.objects.filter(is_active=True)
|
||||||
if feedback.hospital:
|
if feedback.hospital:
|
||||||
assignable_users = assignable_users.filter(hospital=feedback.hospital)
|
assignable_users = assignable_users.filter(hospital=feedback.hospital)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'feedback': feedback,
|
'feedback': feedback,
|
||||||
'timeline': timeline,
|
'timeline': timeline,
|
||||||
@ -241,7 +241,7 @@ def feedback_detail(request, pk):
|
|||||||
'status_choices': FeedbackStatus.choices,
|
'status_choices': FeedbackStatus.choices,
|
||||||
'can_edit': user.is_px_admin() or user.is_hospital_admin(),
|
'can_edit': user.is_px_admin() or user.is_hospital_admin(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'feedback/feedback_detail.html', context)
|
return render(request, 'feedback/feedback_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -254,13 +254,13 @@ def feedback_create(request):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
try:
|
||||||
feedback = form.save(commit=False)
|
feedback = form.save(commit=False)
|
||||||
|
|
||||||
# Set default sentiment if not set
|
# Set default sentiment if not set
|
||||||
if not feedback.sentiment:
|
if not feedback.sentiment:
|
||||||
feedback.sentiment = 'neutral'
|
feedback.sentiment = 'neutral'
|
||||||
|
|
||||||
feedback.save()
|
feedback.save()
|
||||||
|
|
||||||
# Create initial response
|
# Create initial response
|
||||||
FeedbackResponse.objects.create(
|
FeedbackResponse.objects.create(
|
||||||
feedback=feedback,
|
feedback=feedback,
|
||||||
@ -269,7 +269,7 @@ def feedback_create(request):
|
|||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
is_internal=True
|
is_internal=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='feedback_created',
|
event_type='feedback_created',
|
||||||
@ -282,28 +282,28 @@ def feedback_create(request):
|
|||||||
'rating': feedback.rating
|
'rating': feedback.rating
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, f"Feedback #{feedback.id} created successfully.")
|
messages.success(request, f"Feedback #{feedback.id} created successfully.")
|
||||||
return redirect('feedback:feedback_detail', pk=feedback.id)
|
return redirect('feedback:feedback_detail', pk=feedback.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"Error creating feedback: {str(e)}")
|
messages.error(request, f"Error creating feedback: {str(e)}")
|
||||||
else:
|
else:
|
||||||
messages.error(request, "Please correct the errors below.")
|
messages.error(request, "Please correct the errors below.")
|
||||||
else:
|
else:
|
||||||
form = FeedbackForm(user=request.user)
|
form = FeedbackForm(user=request.user)
|
||||||
|
|
||||||
# Get patients for selection
|
# Get patients for selection
|
||||||
patients = Patient.objects.filter(status='active')
|
patients = Patient.objects.filter(status='active')
|
||||||
if request.user.hospital:
|
if request.user.hospital:
|
||||||
patients = patients.filter(primary_hospital=request.user.hospital)
|
patients = patients.filter(primary_hospital=request.user.hospital)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'form': form,
|
'form': form,
|
||||||
'patients': patients,
|
'patients': patients,
|
||||||
'is_create': True,
|
'is_create': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'feedback/feedback_form.html', context)
|
return render(request, 'feedback/feedback_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -312,19 +312,19 @@ def feedback_create(request):
|
|||||||
def feedback_update(request, pk):
|
def feedback_update(request, pk):
|
||||||
"""Update existing feedback"""
|
"""Update existing feedback"""
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
messages.error(request, "You don't have permission to edit feedback.")
|
messages.error(request, "You don't have permission to edit feedback.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = FeedbackForm(request.POST, instance=feedback, user=request.user)
|
form = FeedbackForm(request.POST, instance=feedback, user=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
try:
|
||||||
feedback = form.save()
|
feedback = form.save()
|
||||||
|
|
||||||
# Create update response
|
# Create update response
|
||||||
FeedbackResponse.objects.create(
|
FeedbackResponse.objects.create(
|
||||||
feedback=feedback,
|
feedback=feedback,
|
||||||
@ -333,7 +333,7 @@ def feedback_update(request, pk):
|
|||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
is_internal=True
|
is_internal=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='feedback_updated',
|
event_type='feedback_updated',
|
||||||
@ -341,29 +341,29 @@ def feedback_update(request, pk):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
content_object=feedback
|
content_object=feedback
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, "Feedback updated successfully.")
|
messages.success(request, "Feedback updated successfully.")
|
||||||
return redirect('feedback:feedback_detail', pk=feedback.id)
|
return redirect('feedback:feedback_detail', pk=feedback.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"Error updating feedback: {str(e)}")
|
messages.error(request, f"Error updating feedback: {str(e)}")
|
||||||
else:
|
else:
|
||||||
messages.error(request, "Please correct the errors below.")
|
messages.error(request, "Please correct the errors below.")
|
||||||
else:
|
else:
|
||||||
form = FeedbackForm(instance=feedback, user=request.user)
|
form = FeedbackForm(instance=feedback, user=request.user)
|
||||||
|
|
||||||
# Get patients for selection
|
# Get patients for selection
|
||||||
patients = Patient.objects.filter(status='active')
|
patients = Patient.objects.filter(status='active')
|
||||||
if request.user.hospital:
|
if request.user.hospital:
|
||||||
patients = patients.filter(primary_hospital=request.user.hospital)
|
patients = patients.filter(primary_hospital=request.user.hospital)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'form': form,
|
'form': form,
|
||||||
'feedback': feedback,
|
'feedback': feedback,
|
||||||
'patients': patients,
|
'patients': patients,
|
||||||
'is_create': False,
|
'is_create': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'feedback/feedback_form.html', context)
|
return render(request, 'feedback/feedback_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -372,17 +372,17 @@ def feedback_update(request, pk):
|
|||||||
def feedback_delete(request, pk):
|
def feedback_delete(request, pk):
|
||||||
"""Soft delete feedback"""
|
"""Soft delete feedback"""
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
messages.error(request, "You don't have permission to delete feedback.")
|
messages.error(request, "You don't have permission to delete feedback.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
try:
|
try:
|
||||||
feedback.soft_delete(user=request.user)
|
feedback.soft_delete(user=request.user)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='feedback_deleted',
|
event_type='feedback_deleted',
|
||||||
@ -390,18 +390,18 @@ def feedback_delete(request, pk):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
content_object=feedback
|
content_object=feedback
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, "Feedback deleted successfully.")
|
messages.success(request, "Feedback deleted successfully.")
|
||||||
return redirect('feedback:feedback_list')
|
return redirect('feedback:feedback_list')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"Error deleting feedback: {str(e)}")
|
messages.error(request, f"Error deleting feedback: {str(e)}")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'feedback': feedback,
|
'feedback': feedback,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'feedback/feedback_delete_confirm.html', context)
|
return render(request, 'feedback/feedback_delete_confirm.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -410,31 +410,31 @@ def feedback_delete(request, pk):
|
|||||||
def feedback_assign(request, pk):
|
def feedback_assign(request, pk):
|
||||||
"""Assign feedback to user"""
|
"""Assign feedback to user"""
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
messages.error(request, "You don't have permission to assign feedback.")
|
messages.error(request, "You don't have permission to assign feedback.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
user_id = request.POST.get('user_id')
|
user_id = request.POST.get('user_id')
|
||||||
note = request.POST.get('note', '')
|
note = request.POST.get('note', '')
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
messages.error(request, "Please select a user to assign.")
|
messages.error(request, "Please select a user to assign.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
assignee = User.objects.get(id=user_id)
|
assignee = User.objects.get(id=user_id)
|
||||||
feedback.assigned_to = assignee
|
feedback.assigned_to = assignee
|
||||||
feedback.assigned_at = timezone.now()
|
feedback.assigned_at = timezone.now()
|
||||||
feedback.save(update_fields=['assigned_to', 'assigned_at'])
|
feedback.save(update_fields=['assigned_to', 'assigned_at'])
|
||||||
|
|
||||||
# Create response
|
# Create response
|
||||||
message = f"Assigned to {assignee.get_full_name()}"
|
message = f"Assigned to {assignee.get_full_name()}"
|
||||||
if note:
|
if note:
|
||||||
message += f"\nNote: {note}"
|
message += f"\nNote: {note}"
|
||||||
|
|
||||||
FeedbackResponse.objects.create(
|
FeedbackResponse.objects.create(
|
||||||
feedback=feedback,
|
feedback=feedback,
|
||||||
response_type='assignment',
|
response_type='assignment',
|
||||||
@ -442,7 +442,7 @@ def feedback_assign(request, pk):
|
|||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
is_internal=True
|
is_internal=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='assignment',
|
event_type='assignment',
|
||||||
@ -450,12 +450,12 @@ def feedback_assign(request, pk):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
content_object=feedback
|
content_object=feedback
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, f"Feedback assigned to {assignee.get_full_name()}.")
|
messages.success(request, f"Feedback assigned to {assignee.get_full_name()}.")
|
||||||
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
messages.error(request, "User not found.")
|
messages.error(request, "User not found.")
|
||||||
|
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
|
|
||||||
@ -464,23 +464,23 @@ def feedback_assign(request, pk):
|
|||||||
def feedback_change_status(request, pk):
|
def feedback_change_status(request, pk):
|
||||||
"""Change feedback status"""
|
"""Change feedback status"""
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
messages.error(request, "You don't have permission to change feedback status.")
|
messages.error(request, "You don't have permission to change feedback status.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
new_status = request.POST.get('status')
|
new_status = request.POST.get('status')
|
||||||
note = request.POST.get('note', '')
|
note = request.POST.get('note', '')
|
||||||
|
|
||||||
if not new_status:
|
if not new_status:
|
||||||
messages.error(request, "Please select a status.")
|
messages.error(request, "Please select a status.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
old_status = feedback.status
|
old_status = feedback.status
|
||||||
feedback.status = new_status
|
feedback.status = new_status
|
||||||
|
|
||||||
# Handle status-specific logic
|
# Handle status-specific logic
|
||||||
if new_status == FeedbackStatus.REVIEWED:
|
if new_status == FeedbackStatus.REVIEWED:
|
||||||
feedback.reviewed_at = timezone.now()
|
feedback.reviewed_at = timezone.now()
|
||||||
@ -491,12 +491,12 @@ def feedback_change_status(request, pk):
|
|||||||
elif new_status == FeedbackStatus.CLOSED:
|
elif new_status == FeedbackStatus.CLOSED:
|
||||||
feedback.closed_at = timezone.now()
|
feedback.closed_at = timezone.now()
|
||||||
feedback.closed_by = request.user
|
feedback.closed_by = request.user
|
||||||
|
|
||||||
feedback.save()
|
feedback.save()
|
||||||
|
|
||||||
# Create response
|
# Create response
|
||||||
message = note or f"Status changed from {old_status} to {new_status}"
|
message = note or f"Status changed from {old_status} to {new_status}"
|
||||||
|
|
||||||
FeedbackResponse.objects.create(
|
FeedbackResponse.objects.create(
|
||||||
feedback=feedback,
|
feedback=feedback,
|
||||||
response_type='status_change',
|
response_type='status_change',
|
||||||
@ -506,7 +506,7 @@ def feedback_change_status(request, pk):
|
|||||||
new_status=new_status,
|
new_status=new_status,
|
||||||
is_internal=True
|
is_internal=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='status_change',
|
event_type='status_change',
|
||||||
@ -515,7 +515,7 @@ def feedback_change_status(request, pk):
|
|||||||
content_object=feedback,
|
content_object=feedback,
|
||||||
metadata={'old_status': old_status, 'new_status': new_status}
|
metadata={'old_status': old_status, 'new_status': new_status}
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, f"Feedback status changed to {new_status}.")
|
messages.success(request, f"Feedback status changed to {new_status}.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
@ -525,15 +525,15 @@ def feedback_change_status(request, pk):
|
|||||||
def feedback_add_response(request, pk):
|
def feedback_add_response(request, pk):
|
||||||
"""Add response to feedback"""
|
"""Add response to feedback"""
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
response_type = request.POST.get('response_type', 'response')
|
response_type = request.POST.get('response_type', 'response')
|
||||||
message = request.POST.get('message')
|
message = request.POST.get('message')
|
||||||
is_internal = request.POST.get('is_internal') == 'on'
|
is_internal = request.POST.get('is_internal') == 'on'
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
messages.error(request, "Please enter a response message.")
|
messages.error(request, "Please enter a response message.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
# Create response
|
# Create response
|
||||||
FeedbackResponse.objects.create(
|
FeedbackResponse.objects.create(
|
||||||
feedback=feedback,
|
feedback=feedback,
|
||||||
@ -542,7 +542,7 @@ def feedback_add_response(request, pk):
|
|||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
is_internal=is_internal
|
is_internal=is_internal
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, "Response added successfully.")
|
messages.success(request, "Response added successfully.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
@ -552,19 +552,19 @@ def feedback_add_response(request, pk):
|
|||||||
def feedback_toggle_featured(request, pk):
|
def feedback_toggle_featured(request, pk):
|
||||||
"""Toggle featured status"""
|
"""Toggle featured status"""
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
messages.error(request, "You don't have permission to feature feedback.")
|
messages.error(request, "You don't have permission to feature feedback.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
feedback.is_featured = not feedback.is_featured
|
feedback.is_featured = not feedback.is_featured
|
||||||
feedback.save(update_fields=['is_featured'])
|
feedback.save(update_fields=['is_featured'])
|
||||||
|
|
||||||
status = "featured" if feedback.is_featured else "unfeatured"
|
status = "featured" if feedback.is_featured else "unfeatured"
|
||||||
messages.success(request, f"Feedback {status} successfully.")
|
messages.success(request, f"Feedback {status} successfully.")
|
||||||
|
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
|
|
||||||
@ -573,17 +573,17 @@ def feedback_toggle_featured(request, pk):
|
|||||||
def feedback_toggle_follow_up(request, pk):
|
def feedback_toggle_follow_up(request, pk):
|
||||||
"""Toggle follow-up required status"""
|
"""Toggle follow-up required status"""
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
messages.error(request, "You don't have permission to modify feedback.")
|
messages.error(request, "You don't have permission to modify feedback.")
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|
||||||
feedback.requires_follow_up = not feedback.requires_follow_up
|
feedback.requires_follow_up = not feedback.requires_follow_up
|
||||||
feedback.save(update_fields=['requires_follow_up'])
|
feedback.save(update_fields=['requires_follow_up'])
|
||||||
|
|
||||||
status = "marked for follow-up" if feedback.requires_follow_up else "unmarked for follow-up"
|
status = "marked for follow-up" if feedback.requires_follow_up else "unmarked for follow-up"
|
||||||
messages.success(request, f"Feedback {status} successfully.")
|
messages.success(request, f"Feedback {status} successfully.")
|
||||||
|
|
||||||
return redirect('feedback:feedback_detail', pk=pk)
|
return redirect('feedback:feedback_detail', pk=pk)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:16
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
|||||||
@ -19,32 +19,32 @@ logger = logging.getLogger('apps.integrations')
|
|||||||
def process_inbound_event(self, event_id):
|
def process_inbound_event(self, event_id):
|
||||||
"""
|
"""
|
||||||
Process an inbound integration event.
|
Process an inbound integration event.
|
||||||
|
|
||||||
This is the core event processing task that:
|
This is the core event processing task that:
|
||||||
1. Finds the journey instance by encounter_id
|
1. Finds the journey instance by encounter_id
|
||||||
2. Finds the matching stage by trigger_event_code
|
2. Finds the matching stage by trigger_event_code
|
||||||
3. Completes the stage
|
3. Completes the stage
|
||||||
4. Creates survey instance if configured
|
4. Creates survey instance if configured
|
||||||
5. Logs audit events
|
5. Logs audit events
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event_id: UUID of the InboundEvent to process
|
event_id: UUID of the InboundEvent to process
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Processing result with status and details
|
dict: Processing result with status and details
|
||||||
"""
|
"""
|
||||||
from apps.core.services import create_audit_log
|
from apps.core.services import create_audit_log
|
||||||
from apps.integrations.models import InboundEvent
|
from apps.integrations.models import InboundEvent
|
||||||
from apps.journeys.models import PatientJourneyInstance, PatientJourneyStageInstance, StageStatus
|
from apps.journeys.models import PatientJourneyInstance, PatientJourneyStageInstance, StageStatus
|
||||||
from apps.organizations.models import Department, Physician
|
from apps.organizations.models import Department, Staff
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the event
|
# Get the event
|
||||||
event = InboundEvent.objects.get(id=event_id)
|
event = InboundEvent.objects.get(id=event_id)
|
||||||
event.mark_processing()
|
event.mark_processing()
|
||||||
|
|
||||||
logger.info(f"Processing event {event.id}: {event.event_code} for encounter {event.encounter_id}")
|
logger.info(f"Processing event {event.id}: {event.event_code} for encounter {event.encounter_id}")
|
||||||
|
|
||||||
# Find journey instance by encounter_id
|
# Find journey instance by encounter_id
|
||||||
try:
|
try:
|
||||||
journey_instance = PatientJourneyInstance.objects.select_related(
|
journey_instance = PatientJourneyInstance.objects.select_related(
|
||||||
@ -55,35 +55,35 @@ def process_inbound_event(self, event_id):
|
|||||||
logger.warning(error_msg)
|
logger.warning(error_msg)
|
||||||
event.mark_ignored(error_msg)
|
event.mark_ignored(error_msg)
|
||||||
return {'status': 'ignored', 'reason': error_msg}
|
return {'status': 'ignored', 'reason': error_msg}
|
||||||
|
|
||||||
# Find matching stage by trigger_event_code
|
# Find matching stage by trigger_event_code
|
||||||
matching_stages = journey_instance.stage_instances.filter(
|
matching_stages = journey_instance.stage_instances.filter(
|
||||||
stage_template__trigger_event_code=event.event_code,
|
stage_template__trigger_event_code=event.event_code,
|
||||||
status__in=[StageStatus.PENDING, StageStatus.IN_PROGRESS]
|
status__in=[StageStatus.PENDING, StageStatus.IN_PROGRESS]
|
||||||
).select_related('stage_template')
|
).select_related('stage_template')
|
||||||
|
|
||||||
if not matching_stages.exists():
|
if not matching_stages.exists():
|
||||||
error_msg = f"No pending stage found with trigger {event.event_code}"
|
error_msg = f"No pending stage found with trigger {event.event_code}"
|
||||||
logger.warning(error_msg)
|
logger.warning(error_msg)
|
||||||
event.mark_ignored(error_msg)
|
event.mark_ignored(error_msg)
|
||||||
return {'status': 'ignored', 'reason': error_msg}
|
return {'status': 'ignored', 'reason': error_msg}
|
||||||
|
|
||||||
# Get the first matching stage
|
# Get the first matching stage
|
||||||
stage_instance = matching_stages.first()
|
stage_instance = matching_stages.first()
|
||||||
|
|
||||||
# Extract physician and department from event payload
|
# Extract staff and department from event payload
|
||||||
physician = None
|
staff = None
|
||||||
department = None
|
department = None
|
||||||
|
|
||||||
if event.physician_license:
|
if event.physician_license:
|
||||||
try:
|
try:
|
||||||
physician = Physician.objects.get(
|
staff = Staff.objects.get(
|
||||||
license_number=event.physician_license,
|
license_number=event.physician_license,
|
||||||
hospital=journey_instance.hospital
|
hospital=journey_instance.hospital
|
||||||
)
|
)
|
||||||
except Physician.DoesNotExist:
|
except Staff.DoesNotExist:
|
||||||
logger.warning(f"Physician not found: {event.physician_license}")
|
logger.warning(f"Staff member not found with license: {event.physician_license}")
|
||||||
|
|
||||||
if event.department_code:
|
if event.department_code:
|
||||||
try:
|
try:
|
||||||
department = Department.objects.get(
|
department = Department.objects.get(
|
||||||
@ -92,16 +92,16 @@ def process_inbound_event(self, event_id):
|
|||||||
)
|
)
|
||||||
except Department.DoesNotExist:
|
except Department.DoesNotExist:
|
||||||
logger.warning(f"Department not found: {event.department_code}")
|
logger.warning(f"Department not found: {event.department_code}")
|
||||||
|
|
||||||
# Complete the stage
|
# Complete the stage
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
success = stage_instance.complete(
|
success = stage_instance.complete(
|
||||||
event=event,
|
event=event,
|
||||||
physician=physician,
|
staff=staff,
|
||||||
department=department,
|
department=department,
|
||||||
metadata=event.payload_json
|
metadata=event.payload_json
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Log stage completion
|
# Log stage completion
|
||||||
create_audit_log(
|
create_audit_log(
|
||||||
@ -114,31 +114,31 @@ def process_inbound_event(self, event_id):
|
|||||||
'journey_type': journey_instance.journey_template.journey_type
|
'journey_type': journey_instance.journey_template.journey_type
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if survey should be sent
|
# Check if survey should be sent
|
||||||
if stage_instance.stage_template.auto_send_survey and stage_instance.stage_template.survey_template:
|
if stage_instance.stage_template.auto_send_survey and stage_instance.stage_template.survey_template:
|
||||||
# Queue survey creation task with delay
|
# Queue survey creation task with delay
|
||||||
from apps.surveys.tasks import create_and_send_survey
|
from apps.surveys.tasks import create_and_send_survey
|
||||||
delay_seconds = stage_instance.stage_template.survey_delay_hours * 3600
|
delay_seconds = stage_instance.stage_template.survey_delay_hours * 3600
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Queuing survey for stage {stage_instance.stage_template.name} "
|
f"Queuing survey for stage {stage_instance.stage_template.name} "
|
||||||
f"(delay: {stage_instance.stage_template.survey_delay_hours}h)"
|
f"(delay: {stage_instance.stage_template.survey_delay_hours}h)"
|
||||||
)
|
)
|
||||||
|
|
||||||
create_and_send_survey.apply_async(
|
create_and_send_survey.apply_async(
|
||||||
args=[str(stage_instance.id)],
|
args=[str(stage_instance.id)],
|
||||||
countdown=delay_seconds
|
countdown=delay_seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark event as processed
|
# Mark event as processed
|
||||||
event.mark_processed()
|
event.mark_processed()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully processed event {event.id}: "
|
f"Successfully processed event {event.id}: "
|
||||||
f"Completed stage {stage_instance.stage_template.name}"
|
f"Completed stage {stage_instance.stage_template.name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'processed',
|
'status': 'processed',
|
||||||
'stage_completed': stage_instance.stage_template.name,
|
'stage_completed': stage_instance.stage_template.name,
|
||||||
@ -148,21 +148,21 @@ def process_inbound_event(self, event_id):
|
|||||||
error_msg = "Failed to complete stage"
|
error_msg = "Failed to complete stage"
|
||||||
event.mark_failed(error_msg)
|
event.mark_failed(error_msg)
|
||||||
return {'status': 'failed', 'reason': error_msg}
|
return {'status': 'failed', 'reason': error_msg}
|
||||||
|
|
||||||
except InboundEvent.DoesNotExist:
|
except InboundEvent.DoesNotExist:
|
||||||
error_msg = f"Event {event_id} not found"
|
error_msg = f"Event {event_id} not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {'status': 'error', 'reason': error_msg}
|
return {'status': 'error', 'reason': error_msg}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error processing event: {str(e)}"
|
error_msg = f"Error processing event: {str(e)}"
|
||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event.mark_failed(error_msg)
|
event.mark_failed(error_msg)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Retry the task
|
# Retry the task
|
||||||
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
|
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
|
||||||
|
|
||||||
@ -171,24 +171,24 @@ def process_inbound_event(self, event_id):
|
|||||||
def process_pending_events():
|
def process_pending_events():
|
||||||
"""
|
"""
|
||||||
Periodic task to process pending events.
|
Periodic task to process pending events.
|
||||||
|
|
||||||
This task runs every minute (configured in config/celery.py)
|
This task runs every minute (configured in config/celery.py)
|
||||||
and processes all pending events.
|
and processes all pending events.
|
||||||
"""
|
"""
|
||||||
from apps.integrations.models import InboundEvent
|
from apps.integrations.models import InboundEvent
|
||||||
|
|
||||||
pending_events = InboundEvent.objects.filter(
|
pending_events = InboundEvent.objects.filter(
|
||||||
status='pending'
|
status='pending'
|
||||||
).order_by('received_at')[:100] # Process max 100 at a time
|
).order_by('received_at')[:100] # Process max 100 at a time
|
||||||
|
|
||||||
processed_count = 0
|
processed_count = 0
|
||||||
|
|
||||||
for event in pending_events:
|
for event in pending_events:
|
||||||
# Queue individual event for processing
|
# Queue individual event for processing
|
||||||
process_inbound_event.delay(str(event.id))
|
process_inbound_event.delay(str(event.id))
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
|
|
||||||
if processed_count > 0:
|
if processed_count > 0:
|
||||||
logger.info(f"Queued {processed_count} pending events for processing")
|
logger.info(f"Queued {processed_count} pending events for processing")
|
||||||
|
|
||||||
return {'queued': processed_count}
|
return {'queued': processed_count}
|
||||||
|
|||||||
@ -30,15 +30,15 @@ class PatientJourneyTemplateAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ['name', 'name_ar', 'description']
|
search_fields = ['name', 'name_ar', 'description']
|
||||||
ordering = ['hospital', 'journey_type', 'name']
|
ordering = ['hospital', 'journey_type', 'name']
|
||||||
inlines = [PatientJourneyStageTemplateInline]
|
inlines = [PatientJourneyStageTemplateInline]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('name', 'name_ar', 'journey_type', 'description')}),
|
(None, {'fields': ('name', 'name_ar', 'journey_type', 'description')}),
|
||||||
('Configuration', {'fields': ('hospital', 'is_active', 'is_default')}),
|
('Configuration', {'fields': ('hospital', 'is_active', 'is_default')}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital')
|
return qs.select_related('hospital')
|
||||||
@ -54,7 +54,7 @@ class PatientJourneyStageTemplateAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ['journey_template__journey_type', 'auto_send_survey', 'is_optional', 'is_active']
|
list_filter = ['journey_template__journey_type', 'auto_send_survey', 'is_optional', 'is_active']
|
||||||
search_fields = ['name', 'name_ar', 'code', 'trigger_event_code']
|
search_fields = ['name', 'name_ar', 'code', 'trigger_event_code']
|
||||||
ordering = ['journey_template', 'order']
|
ordering = ['journey_template', 'order']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('journey_template', 'name', 'name_ar', 'code', 'order')}),
|
(None, {'fields': ('journey_template', 'name', 'name_ar', 'code', 'order')}),
|
||||||
('Event Trigger', {'fields': ('trigger_event_code',)}),
|
('Event Trigger', {'fields': ('trigger_event_code',)}),
|
||||||
@ -69,9 +69,9 @@ class PatientJourneyStageTemplateAdmin(admin.ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('journey_template', 'survey_template')
|
return qs.select_related('journey_template', 'survey_template')
|
||||||
@ -83,11 +83,11 @@ class PatientJourneyStageInstanceInline(admin.TabularInline):
|
|||||||
extra = 0
|
extra = 0
|
||||||
fields = [
|
fields = [
|
||||||
'stage_template', 'status', 'completed_at',
|
'stage_template', 'status', 'completed_at',
|
||||||
'physician', 'department', 'survey_instance'
|
'staff', 'department', 'survey_instance'
|
||||||
]
|
]
|
||||||
readonly_fields = ['stage_template', 'completed_at', 'survey_instance']
|
readonly_fields = ['stage_template', 'completed_at', 'survey_instance']
|
||||||
ordering = ['stage_template__order']
|
ordering = ['stage_template__order']
|
||||||
|
|
||||||
def has_add_permission(self, request, obj=None):
|
def has_add_permission(self, request, obj=None):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ class PatientJourneyInstanceAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ['encounter_id', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
search_fields = ['encounter_id', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
||||||
ordering = ['-started_at']
|
ordering = ['-started_at']
|
||||||
inlines = [PatientJourneyStageInstanceInline]
|
inlines = [PatientJourneyStageInstanceInline]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'fields': ('journey_template', 'patient', 'encounter_id')
|
'fields': ('journey_template', 'patient', 'encounter_id')
|
||||||
@ -119,15 +119,15 @@ class PatientJourneyInstanceAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['started_at', 'completed_at', 'created_at', 'updated_at']
|
readonly_fields = ['started_at', 'completed_at', 'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related(
|
return qs.select_related(
|
||||||
'journey_template', 'patient', 'hospital', 'department'
|
'journey_template', 'patient', 'hospital', 'department'
|
||||||
).prefetch_related('stage_instances')
|
).prefetch_related('stage_instances')
|
||||||
|
|
||||||
def get_completion_percentage(self, obj):
|
def get_completion_percentage(self, obj):
|
||||||
"""Display completion percentage"""
|
"""Display completion percentage"""
|
||||||
return f"{obj.get_completion_percentage()}%"
|
return f"{obj.get_completion_percentage()}%"
|
||||||
@ -139,7 +139,7 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
|
|||||||
"""Journey stage instance admin"""
|
"""Journey stage instance admin"""
|
||||||
list_display = [
|
list_display = [
|
||||||
'journey_instance', 'stage_template', 'status',
|
'journey_instance', 'stage_template', 'status',
|
||||||
'completed_at', 'physician', 'survey_instance'
|
'completed_at', 'staff', 'survey_instance'
|
||||||
]
|
]
|
||||||
list_filter = ['status', 'stage_template__journey_template__journey_type', 'completed_at']
|
list_filter = ['status', 'stage_template__journey_template__journey_type', 'completed_at']
|
||||||
search_fields = [
|
search_fields = [
|
||||||
@ -148,13 +148,13 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
|
|||||||
'stage_template__name'
|
'stage_template__name'
|
||||||
]
|
]
|
||||||
ordering = ['journey_instance', 'stage_template__order']
|
ordering = ['journey_instance', 'stage_template__order']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'fields': ('journey_instance', 'stage_template', 'status')
|
'fields': ('journey_instance', 'stage_template', 'status')
|
||||||
}),
|
}),
|
||||||
('Completion Details', {
|
('Completion Details', {
|
||||||
'fields': ('completed_at', 'completed_by_event', 'physician', 'department')
|
'fields': ('completed_at', 'completed_by_event', 'staff', 'department')
|
||||||
}),
|
}),
|
||||||
('Survey', {
|
('Survey', {
|
||||||
'fields': ('survey_instance', 'survey_sent_at')
|
'fields': ('survey_instance', 'survey_sent_at')
|
||||||
@ -164,15 +164,15 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['completed_at', 'completed_by_event', 'survey_sent_at', 'created_at', 'updated_at']
|
readonly_fields = ['completed_at', 'completed_by_event', 'survey_sent_at', 'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related(
|
return qs.select_related(
|
||||||
'journey_instance',
|
'journey_instance',
|
||||||
'stage_template',
|
'stage_template',
|
||||||
'physician',
|
'staff',
|
||||||
'department',
|
'department',
|
||||||
'survey_instance',
|
'survey_instance',
|
||||||
'completed_by_event'
|
'completed_by_event'
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:16
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
@ -87,7 +87,7 @@ class Migration(migrations.Migration):
|
|||||||
('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')),
|
('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')),
|
('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')),
|
('journey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stage_instances', to='journeys.patientjourneyinstance')),
|
||||||
('physician', models.ForeignKey(blank=True, help_text='Physician associated with this stage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='organizations.physician')),
|
('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={
|
options={
|
||||||
'ordering': ['journey_instance', 'stage_template__order'],
|
'ordering': ['journey_instance', 'stage_template__order'],
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:16
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -294,13 +294,13 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Context from event
|
# Context from event
|
||||||
physician = models.ForeignKey(
|
staff = models.ForeignKey(
|
||||||
'organizations.Physician',
|
'organizations.Staff',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='journey_stages',
|
related_name='journey_stages',
|
||||||
help_text="Physician associated with this stage"
|
help_text="Staff member associated with this stage"
|
||||||
)
|
)
|
||||||
department = models.ForeignKey(
|
department = models.ForeignKey(
|
||||||
'organizations.Department',
|
'organizations.Department',
|
||||||
@ -344,15 +344,15 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
|
|||||||
"""Check if this stage can be completed"""
|
"""Check if this stage can be completed"""
|
||||||
return self.status in [StageStatus.PENDING, StageStatus.IN_PROGRESS]
|
return self.status in [StageStatus.PENDING, StageStatus.IN_PROGRESS]
|
||||||
|
|
||||||
def complete(self, event=None, physician=None, department=None, metadata=None):
|
def complete(self, event=None, staff=None, department=None, metadata=None):
|
||||||
"""
|
"""
|
||||||
Mark stage as completed.
|
Mark stage as completed.
|
||||||
|
|
||||||
This method should be called by the event processing task.
|
This method should be called by event processing task.
|
||||||
It will:
|
It will:
|
||||||
1. Update status to COMPLETED
|
1. Update status to COMPLETED
|
||||||
2. Set completion timestamp
|
2. Set completion timestamp
|
||||||
3. Attach event, physician, department
|
3. Attach event, staff, department
|
||||||
4. Trigger survey creation if configured
|
4. Trigger survey creation if configured
|
||||||
"""
|
"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -364,8 +364,8 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
|
|||||||
self.completed_at = timezone.now()
|
self.completed_at = timezone.now()
|
||||||
self.completed_by_event = event
|
self.completed_by_event = event
|
||||||
|
|
||||||
if physician:
|
if staff:
|
||||||
self.physician = physician
|
self.staff = staff
|
||||||
if department:
|
if department:
|
||||||
self.department = department
|
self.department = department
|
||||||
if metadata:
|
if metadata:
|
||||||
|
|||||||
@ -27,7 +27,7 @@ from .serializers import (
|
|||||||
class PatientJourneyTemplateViewSet(viewsets.ModelViewSet):
|
class PatientJourneyTemplateViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Journey Templates.
|
ViewSet for Journey Templates.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- PX Admins and Hospital Admins can manage templates
|
- PX Admins and Hospital Admins can manage templates
|
||||||
- Others can view templates
|
- Others can view templates
|
||||||
@ -35,35 +35,35 @@ class PatientJourneyTemplateViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = PatientJourneyTemplate.objects.all()
|
queryset = PatientJourneyTemplate.objects.all()
|
||||||
serializer_class = PatientJourneyTemplateSerializer
|
serializer_class = PatientJourneyTemplateSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['journey_type', 'hospital', 'is_active', 'is_default']
|
filterset_fields = ['journey_type', 'hospital', 'is_active', 'is_default', 'hospital__organization']
|
||||||
search_fields = ['name', 'name_ar', 'description']
|
search_fields = ['name', 'name_ar', 'description']
|
||||||
ordering_fields = ['name', 'created_at']
|
ordering_fields = ['name', 'created_at']
|
||||||
ordering = ['hospital', 'journey_type', 'name']
|
ordering = ['hospital', 'journey_type', 'name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter templates based on user role"""
|
"""Filter templates based on user role"""
|
||||||
queryset = super().get_queryset().select_related('hospital').prefetch_related('stages')
|
queryset = super().get_queryset().select_related('hospital').prefetch_related('stages')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all templates
|
# PX Admins see all templates
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see templates for their hospital
|
# Hospital Admins see templates for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Others see templates for their hospital
|
# Others see templates for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class PatientJourneyStageTemplateViewSet(viewsets.ModelViewSet):
|
class PatientJourneyStageTemplateViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Journey Stage Templates.
|
ViewSet for Journey Stage Templates.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- PX Admins and Hospital Admins can manage stage templates
|
- PX Admins and Hospital Admins can manage stage templates
|
||||||
"""
|
"""
|
||||||
@ -74,26 +74,26 @@ class PatientJourneyStageTemplateViewSet(viewsets.ModelViewSet):
|
|||||||
search_fields = ['name', 'name_ar', 'code', 'trigger_event_code']
|
search_fields = ['name', 'name_ar', 'code', 'trigger_event_code']
|
||||||
ordering_fields = ['order', 'name']
|
ordering_fields = ['order', 'name']
|
||||||
ordering = ['journey_template', 'order']
|
ordering = ['journey_template', 'order']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset().select_related('journey_template', 'survey_template')
|
queryset = super().get_queryset().select_related('journey_template', 'survey_template')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all stage templates
|
# PX Admins see all stage templates
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see stage templates for their hospital
|
# Hospital Admins see stage templates for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(journey_template__hospital=user.hospital)
|
return queryset.filter(journey_template__hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Journey Instances.
|
ViewSet for Journey Instances.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- All authenticated users can view journey instances
|
- All authenticated users can view journey instances
|
||||||
- PX Admins and Hospital Admins can create/manage instances
|
- PX Admins and Hospital Admins can create/manage instances
|
||||||
@ -102,60 +102,61 @@ class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = [
|
filterset_fields = [
|
||||||
'journey_template', 'journey_template__journey_type',
|
'journey_template', 'journey_template__journey_type',
|
||||||
'patient', 'hospital', 'department', 'status'
|
'patient', 'hospital', 'department', 'status',
|
||||||
|
'hospital__organization'
|
||||||
]
|
]
|
||||||
search_fields = ['encounter_id', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
search_fields = ['encounter_id', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
||||||
ordering_fields = ['started_at', 'completed_at']
|
ordering_fields = ['started_at', 'completed_at']
|
||||||
ordering = ['-started_at']
|
ordering = ['-started_at']
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
"""Use simplified serializer for list view"""
|
"""Use simplified serializer for list view"""
|
||||||
if self.action == 'list':
|
if self.action == 'list':
|
||||||
return PatientJourneyInstanceListSerializer
|
return PatientJourneyInstanceListSerializer
|
||||||
return PatientJourneyInstanceSerializer
|
return PatientJourneyInstanceSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter journey instances based on user role"""
|
"""Filter journey instances based on user role"""
|
||||||
queryset = super().get_queryset().select_related(
|
queryset = super().get_queryset().select_related(
|
||||||
'journey_template', 'patient', 'hospital', 'department'
|
'journey_template', 'patient', 'hospital', 'department'
|
||||||
).prefetch_related('stage_instances__stage_template')
|
).prefetch_related('stage_instances__stage_template')
|
||||||
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all journey instances
|
# PX Admins see all journey instances
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see instances for their hospital
|
# Hospital Admins see instances for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Department Managers see instances for their department
|
# Department Managers see instances for their department
|
||||||
if user.is_department_manager() and user.department:
|
if user.is_department_manager() and user.department:
|
||||||
return queryset.filter(department=user.department)
|
return queryset.filter(department=user.department)
|
||||||
|
|
||||||
# Others see instances for their hospital
|
# Others see instances for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""
|
"""
|
||||||
Create journey instance and initialize stage instances.
|
Create journey instance and initialize stage instances.
|
||||||
|
|
||||||
When a journey instance is created, automatically create
|
When a journey instance is created, automatically create
|
||||||
stage instances for all stages in the template.
|
stage instances for all stages in the template.
|
||||||
"""
|
"""
|
||||||
journey_instance = serializer.save()
|
journey_instance = serializer.save()
|
||||||
|
|
||||||
# Create stage instances for all stages in the template
|
# Create stage instances for all stages in the template
|
||||||
for stage_template in journey_instance.journey_template.stages.filter(is_active=True):
|
for stage_template in journey_instance.journey_template.stages.filter(is_active=True):
|
||||||
PatientJourneyStageInstance.objects.create(
|
PatientJourneyStageInstance.objects.create(
|
||||||
journey_instance=journey_instance,
|
journey_instance=journey_instance,
|
||||||
stage_template=stage_template
|
stage_template=stage_template
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log journey creation
|
# Log journey creation
|
||||||
AuditService.log_from_request(
|
AuditService.log_from_request(
|
||||||
event_type='journey_started',
|
event_type='journey_started',
|
||||||
@ -167,14 +168,14 @@ class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
|||||||
'patient_mrn': journey_instance.patient.mrn
|
'patient_mrn': journey_instance.patient.mrn
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def progress(self, request, pk=None):
|
def progress(self, request, pk=None):
|
||||||
"""Get journey progress summary"""
|
"""Get journey progress summary"""
|
||||||
journey = self.get_object()
|
journey = self.get_object()
|
||||||
|
|
||||||
stages = journey.stage_instances.select_related('stage_template').order_by('stage_template__order')
|
stages = journey.stage_instances.select_related('stage_template').order_by('stage_template__order')
|
||||||
|
|
||||||
progress_data = {
|
progress_data = {
|
||||||
'journey_id': str(journey.id),
|
'journey_id': str(journey.id),
|
||||||
'encounter_id': journey.encounter_id,
|
'encounter_id': journey.encounter_id,
|
||||||
@ -196,14 +197,14 @@ class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
|||||||
for stage in stages
|
for stage in stages
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response(progress_data)
|
return Response(progress_data)
|
||||||
|
|
||||||
|
|
||||||
class PatientJourneyStageInstanceViewSet(viewsets.ReadOnlyModelViewSet):
|
class PatientJourneyStageInstanceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Journey Stage Instances (read-only).
|
ViewSet for Journey Stage Instances (read-only).
|
||||||
|
|
||||||
Stage instances are created automatically and updated via event processing.
|
Stage instances are created automatically and updated via event processing.
|
||||||
Manual updates should be done through the admin interface.
|
Manual updates should be done through the admin interface.
|
||||||
"""
|
"""
|
||||||
@ -214,7 +215,7 @@ class PatientJourneyStageInstanceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
search_fields = ['journey_instance__encounter_id', 'stage_template__name']
|
search_fields = ['journey_instance__encounter_id', 'stage_template__name']
|
||||||
ordering_fields = ['completed_at', 'created_at']
|
ordering_fields = ['completed_at', 'created_at']
|
||||||
ordering = ['journey_instance', 'stage_template__order']
|
ordering = ['journey_instance', 'stage_template__order']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter stage instances based on user role"""
|
"""Filter stage instances based on user role"""
|
||||||
queryset = super().get_queryset().select_related(
|
queryset = super().get_queryset().select_related(
|
||||||
@ -224,23 +225,23 @@ class PatientJourneyStageInstanceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
'department',
|
'department',
|
||||||
'survey_instance'
|
'survey_instance'
|
||||||
)
|
)
|
||||||
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all stage instances
|
# PX Admins see all stage instances
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see instances for their hospital
|
# Hospital Admins see instances for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(journey_instance__hospital=user.hospital)
|
return queryset.filter(journey_instance__hospital=user.hospital)
|
||||||
|
|
||||||
# Department Managers see instances for their department
|
# Department Managers see instances for their department
|
||||||
if user.is_department_manager() and user.department:
|
if user.is_department_manager() and user.department:
|
||||||
return queryset.filter(journey_instance__department=user.department)
|
return queryset.filter(journey_instance__department=user.department)
|
||||||
|
|
||||||
# Others see instances for their hospital
|
# Others see instances for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(journey_instance__hospital=user.hospital)
|
return queryset.filter(journey_instance__hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:38
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
|||||||
@ -20,23 +20,23 @@ logger = logging.getLogger(__name__)
|
|||||||
class NotificationService:
|
class NotificationService:
|
||||||
"""
|
"""
|
||||||
Unified notification service for all channels.
|
Unified notification service for all channels.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
NotificationService.send_sms('+966501234567', 'Your survey is ready')
|
NotificationService.send_sms('+966501234567', 'Your survey is ready')
|
||||||
NotificationService.send_email('user@email.com', 'Survey', 'Please complete...')
|
NotificationService.send_email('user@email.com', 'Survey', 'Please complete...')
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_sms(phone, message, related_object=None, metadata=None):
|
def send_sms(phone, message, related_object=None, metadata=None):
|
||||||
"""
|
"""
|
||||||
Send SMS notification.
|
Send SMS notification.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
phone: Recipient phone number
|
phone: Recipient phone number
|
||||||
message: SMS message text
|
message: SMS message text
|
||||||
related_object: Related model instance (optional)
|
related_object: Related model instance (optional)
|
||||||
metadata: Additional metadata dict (optional)
|
metadata: Additional metadata dict (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NotificationLog instance
|
NotificationLog instance
|
||||||
"""
|
"""
|
||||||
@ -49,14 +49,14 @@ class NotificationService:
|
|||||||
provider='console', # TODO: Replace with actual provider
|
provider='console', # TODO: Replace with actual provider
|
||||||
metadata=metadata or {}
|
metadata=metadata or {}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if SMS is enabled
|
# Check if SMS is enabled
|
||||||
sms_config = settings.NOTIFICATION_CHANNELS.get('sms', {})
|
sms_config = settings.NOTIFICATION_CHANNELS.get('sms', {})
|
||||||
if not sms_config.get('enabled', False):
|
if not sms_config.get('enabled', False):
|
||||||
logger.info(f"[SMS Console] To: {phone} | Message: {message}")
|
logger.info(f"[SMS Console] To: {phone} | Message: {message}")
|
||||||
log.mark_sent()
|
log.mark_sent()
|
||||||
return log
|
return log
|
||||||
|
|
||||||
# TODO: Integrate with actual SMS provider (Twilio, etc.)
|
# TODO: Integrate with actual SMS provider (Twilio, etc.)
|
||||||
# Example:
|
# Example:
|
||||||
# try:
|
# try:
|
||||||
@ -70,24 +70,24 @@ class NotificationService:
|
|||||||
# log.mark_sent(provider_message_id=message.sid)
|
# log.mark_sent(provider_message_id=message.sid)
|
||||||
# except Exception as e:
|
# except Exception as e:
|
||||||
# log.mark_failed(str(e))
|
# log.mark_failed(str(e))
|
||||||
|
|
||||||
# Console backend for now
|
# Console backend for now
|
||||||
logger.info(f"[SMS Console] To: {phone} | Message: {message}")
|
logger.info(f"[SMS Console] To: {phone} | Message: {message}")
|
||||||
log.mark_sent()
|
log.mark_sent()
|
||||||
|
|
||||||
return log
|
return log
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_whatsapp(phone, message, related_object=None, metadata=None):
|
def send_whatsapp(phone, message, related_object=None, metadata=None):
|
||||||
"""
|
"""
|
||||||
Send WhatsApp notification.
|
Send WhatsApp notification.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
phone: Recipient phone number
|
phone: Recipient phone number
|
||||||
message: WhatsApp message text
|
message: WhatsApp message text
|
||||||
related_object: Related model instance (optional)
|
related_object: Related model instance (optional)
|
||||||
metadata: Additional metadata dict (optional)
|
metadata: Additional metadata dict (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NotificationLog instance
|
NotificationLog instance
|
||||||
"""
|
"""
|
||||||
@ -100,14 +100,14 @@ class NotificationService:
|
|||||||
provider='console', # TODO: Replace with actual provider
|
provider='console', # TODO: Replace with actual provider
|
||||||
metadata=metadata or {}
|
metadata=metadata or {}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if WhatsApp is enabled
|
# Check if WhatsApp is enabled
|
||||||
whatsapp_config = settings.NOTIFICATION_CHANNELS.get('whatsapp', {})
|
whatsapp_config = settings.NOTIFICATION_CHANNELS.get('whatsapp', {})
|
||||||
if not whatsapp_config.get('enabled', False):
|
if not whatsapp_config.get('enabled', False):
|
||||||
logger.info(f"[WhatsApp Console] To: {phone} | Message: {message}")
|
logger.info(f"[WhatsApp Console] To: {phone} | Message: {message}")
|
||||||
log.mark_sent()
|
log.mark_sent()
|
||||||
return log
|
return log
|
||||||
|
|
||||||
# TODO: Integrate with WhatsApp Business API
|
# TODO: Integrate with WhatsApp Business API
|
||||||
# Example:
|
# Example:
|
||||||
# try:
|
# try:
|
||||||
@ -123,18 +123,18 @@ class NotificationService:
|
|||||||
# log.mark_sent(provider_message_id=response.json().get('id'))
|
# log.mark_sent(provider_message_id=response.json().get('id'))
|
||||||
# except Exception as e:
|
# except Exception as e:
|
||||||
# log.mark_failed(str(e))
|
# log.mark_failed(str(e))
|
||||||
|
|
||||||
# Console backend for now
|
# Console backend for now
|
||||||
logger.info(f"[WhatsApp Console] To: {phone} | Message: {message}")
|
logger.info(f"[WhatsApp Console] To: {phone} | Message: {message}")
|
||||||
log.mark_sent()
|
log.mark_sent()
|
||||||
|
|
||||||
return log
|
return log
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_email(email, subject, message, html_message=None, related_object=None, metadata=None):
|
def send_email(email, subject, message, html_message=None, related_object=None, metadata=None):
|
||||||
"""
|
"""
|
||||||
Send Email notification.
|
Send Email notification.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
email: Recipient email address
|
email: Recipient email address
|
||||||
subject: Email subject
|
subject: Email subject
|
||||||
@ -142,7 +142,7 @@ class NotificationService:
|
|||||||
html_message: Email message (HTML) (optional)
|
html_message: Email message (HTML) (optional)
|
||||||
related_object: Related model instance (optional)
|
related_object: Related model instance (optional)
|
||||||
metadata: Additional metadata dict (optional)
|
metadata: Additional metadata dict (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NotificationLog instance
|
NotificationLog instance
|
||||||
"""
|
"""
|
||||||
@ -156,14 +156,14 @@ class NotificationService:
|
|||||||
provider='console', # TODO: Replace with actual provider
|
provider='console', # TODO: Replace with actual provider
|
||||||
metadata=metadata or {}
|
metadata=metadata or {}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if Email is enabled
|
# Check if Email is enabled
|
||||||
email_config = settings.NOTIFICATION_CHANNELS.get('email', {})
|
email_config = settings.NOTIFICATION_CHANNELS.get('email', {})
|
||||||
if not email_config.get('enabled', True):
|
if not email_config.get('enabled', True):
|
||||||
logger.info(f"[Email Console] To: {email} | Subject: {subject} | Message: {message}")
|
logger.info(f"[Email Console] To: {email} | Subject: {subject} | Message: {message}")
|
||||||
log.mark_sent()
|
log.mark_sent()
|
||||||
return log
|
return log
|
||||||
|
|
||||||
# Send email using Django's email backend
|
# Send email using Django's email backend
|
||||||
try:
|
try:
|
||||||
send_mail(
|
send_mail(
|
||||||
@ -179,24 +179,101 @@ class NotificationService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.mark_failed(str(e))
|
log.mark_failed(str(e))
|
||||||
logger.error(f"Failed to send email to {email}: {str(e)}")
|
logger.error(f"Failed to send email to {email}: {str(e)}")
|
||||||
|
|
||||||
return log
|
return log
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_notification(recipient, title, message, notification_type='general', related_object=None, metadata=None):
|
||||||
|
"""
|
||||||
|
Send generic notification to a user.
|
||||||
|
|
||||||
|
This method determines the best channel to use based on recipient preferences
|
||||||
|
or defaults to email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipient: User object
|
||||||
|
title: Notification title
|
||||||
|
message: Notification message
|
||||||
|
notification_type: Type of notification (e.g., 'complaint', 'survey', 'general')
|
||||||
|
related_object: Related model instance (optional)
|
||||||
|
metadata: Additional metadata dict (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NotificationLog instance
|
||||||
|
"""
|
||||||
|
# Determine the recipient's contact information
|
||||||
|
recipient_email = recipient.email if hasattr(recipient, 'email') else None
|
||||||
|
recipient_phone = recipient.phone if hasattr(recipient, 'phone') else None
|
||||||
|
|
||||||
|
# Try email first (most reliable)
|
||||||
|
if recipient_email:
|
||||||
|
try:
|
||||||
|
return NotificationService.send_email(
|
||||||
|
email=recipient_email,
|
||||||
|
subject=title,
|
||||||
|
message=message,
|
||||||
|
related_object=related_object,
|
||||||
|
metadata={
|
||||||
|
'notification_type': notification_type,
|
||||||
|
**(metadata or {})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send email notification to {recipient_email}: {str(e)}")
|
||||||
|
|
||||||
|
# Fallback to SMS if email failed or not available
|
||||||
|
if recipient_phone:
|
||||||
|
try:
|
||||||
|
# Combine title and message for SMS
|
||||||
|
sms_message = f"{title}\n\n{message}"
|
||||||
|
return NotificationService.send_sms(
|
||||||
|
phone=recipient_phone,
|
||||||
|
message=sms_message,
|
||||||
|
related_object=related_object,
|
||||||
|
metadata={
|
||||||
|
'notification_type': notification_type,
|
||||||
|
**(metadata or {})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send SMS notification to {recipient_phone}: {str(e)}")
|
||||||
|
|
||||||
|
# If all channels failed, log a console notification
|
||||||
|
logger.warning(
|
||||||
|
f"Could not send notification to {recipient}. "
|
||||||
|
f"No valid contact channels available. "
|
||||||
|
f"Title: {title}, Message: {message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a log entry even if we couldn't send
|
||||||
|
return NotificationLog.objects.create(
|
||||||
|
channel='console',
|
||||||
|
recipient=str(recipient),
|
||||||
|
subject=title,
|
||||||
|
message=message,
|
||||||
|
content_object=related_object,
|
||||||
|
provider='console',
|
||||||
|
metadata={
|
||||||
|
'notification_type': notification_type,
|
||||||
|
**(metadata or {})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_survey_invitation(survey_instance, language='en'):
|
def send_survey_invitation(survey_instance, language='en'):
|
||||||
"""
|
"""
|
||||||
Send survey invitation to patient.
|
Send survey invitation to patient.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
survey_instance: SurveyInstance object
|
survey_instance: SurveyInstance object
|
||||||
language: Language code ('en' or 'ar')
|
language: Language code ('en' or 'ar')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NotificationLog instance
|
NotificationLog instance
|
||||||
"""
|
"""
|
||||||
patient = survey_instance.patient
|
patient = survey_instance.patient
|
||||||
survey_url = survey_instance.get_survey_url()
|
survey_url = survey_instance.get_survey_url()
|
||||||
|
|
||||||
# Determine recipient based on delivery channel
|
# Determine recipient based on delivery channel
|
||||||
if survey_instance.delivery_channel == 'sms':
|
if survey_instance.delivery_channel == 'sms':
|
||||||
recipient = survey_instance.recipient_phone or patient.phone
|
recipient = survey_instance.recipient_phone or patient.phone
|
||||||
@ -204,28 +281,28 @@ class NotificationService:
|
|||||||
message = f"عزيزي {patient.get_full_name()},\n\nنرجو منك إكمال استبيان تجربتك:\n{survey_url}"
|
message = f"عزيزي {patient.get_full_name()},\n\nنرجو منك إكمال استبيان تجربتك:\n{survey_url}"
|
||||||
else:
|
else:
|
||||||
message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}"
|
message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}"
|
||||||
|
|
||||||
return NotificationService.send_sms(
|
return NotificationService.send_sms(
|
||||||
phone=recipient,
|
phone=recipient,
|
||||||
message=message,
|
message=message,
|
||||||
related_object=survey_instance,
|
related_object=survey_instance,
|
||||||
metadata={'survey_id': str(survey_instance.id), 'language': language}
|
metadata={'survey_id': str(survey_instance.id), 'language': language}
|
||||||
)
|
)
|
||||||
|
|
||||||
elif survey_instance.delivery_channel == 'whatsapp':
|
elif survey_instance.delivery_channel == 'whatsapp':
|
||||||
recipient = survey_instance.recipient_phone or patient.phone
|
recipient = survey_instance.recipient_phone or patient.phone
|
||||||
if language == 'ar':
|
if language == 'ar':
|
||||||
message = f"عزيزي {patient.get_full_name()},\n\nنرجو منك إكمال استبيان تجربتك:\n{survey_url}"
|
message = f"عزيزي {patient.get_full_name()},\n\nنرجو منك إكمال استبيان تجربتك:\n{survey_url}"
|
||||||
else:
|
else:
|
||||||
message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}"
|
message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}"
|
||||||
|
|
||||||
return NotificationService.send_whatsapp(
|
return NotificationService.send_whatsapp(
|
||||||
phone=recipient,
|
phone=recipient,
|
||||||
message=message,
|
message=message,
|
||||||
related_object=survey_instance,
|
related_object=survey_instance,
|
||||||
metadata={'survey_id': str(survey_instance.id), 'language': language}
|
metadata={'survey_id': str(survey_instance.id), 'language': language}
|
||||||
)
|
)
|
||||||
|
|
||||||
else: # email
|
else: # email
|
||||||
recipient = survey_instance.recipient_email or patient.email
|
recipient = survey_instance.recipient_email or patient.email
|
||||||
if language == 'ar':
|
if language == 'ar':
|
||||||
@ -234,7 +311,7 @@ class NotificationService:
|
|||||||
else:
|
else:
|
||||||
subject = f"Your Experience Survey - {survey_instance.survey_template.name}"
|
subject = f"Your Experience Survey - {survey_instance.survey_template.name}"
|
||||||
message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}"
|
message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}"
|
||||||
|
|
||||||
return NotificationService.send_email(
|
return NotificationService.send_email(
|
||||||
email=recipient,
|
email=recipient,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
|
|||||||
@ -3,7 +3,25 @@ Organizations admin
|
|||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Department, Employee, Hospital, Patient, Physician
|
from .models import Department, Hospital, Organization, Patient, Staff
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Organization)
|
||||||
|
class OrganizationAdmin(admin.ModelAdmin):
|
||||||
|
"""Organization admin"""
|
||||||
|
list_display = ['name', 'code', 'city', 'status', 'created_at']
|
||||||
|
list_filter = ['status', 'city']
|
||||||
|
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(None, {'fields': ('name', 'name_ar', 'code')}),
|
||||||
|
('Contact Information', {'fields': ('address', 'city', 'phone', 'email', 'website')}),
|
||||||
|
('Details', {'fields': ('license_number', 'status', 'logo')}),
|
||||||
|
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Hospital)
|
@admin.register(Hospital)
|
||||||
@ -13,14 +31,15 @@ class HospitalAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ['status', 'city']
|
list_filter = ['status', 'city']
|
||||||
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('name', 'name_ar', 'code')}),
|
(None, {'fields': ('organization', 'name', 'name_ar', 'code')}),
|
||||||
('Contact Information', {'fields': ('address', 'city', 'phone', 'email')}),
|
('Contact Information', {'fields': ('address', 'city', 'phone', 'email')}),
|
||||||
('Details', {'fields': ('license_number', 'capacity', 'status')}),
|
('Details', {'fields': ('license_number', 'capacity', 'status')}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||||
)
|
)
|
||||||
|
autocomplete_fields = ['organization']
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
@ -32,7 +51,7 @@ class DepartmentAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ['name', 'name_ar', 'code']
|
search_fields = ['name', 'name_ar', 'code']
|
||||||
ordering = ['hospital', 'name']
|
ordering = ['hospital', 'name']
|
||||||
autocomplete_fields = ['hospital', 'parent', 'manager']
|
autocomplete_fields = ['hospital', 'parent', 'manager']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('hospital', 'name', 'name_ar', 'code')}),
|
(None, {'fields': ('hospital', 'name', 'name_ar', 'code')}),
|
||||||
('Hierarchy', {'fields': ('parent', 'manager')}),
|
('Hierarchy', {'fields': ('parent', 'manager')}),
|
||||||
@ -40,63 +59,40 @@ class DepartmentAdmin(admin.ModelAdmin):
|
|||||||
('Status', {'fields': ('status',)}),
|
('Status', {'fields': ('status',)}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital', 'manager', 'parent')
|
return qs.select_related('hospital', 'manager', 'parent')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Physician)
|
@admin.register(Staff)
|
||||||
class PhysicianAdmin(admin.ModelAdmin):
|
class StaffAdmin(admin.ModelAdmin):
|
||||||
"""Physician admin"""
|
"""Staff admin"""
|
||||||
list_display = ['get_full_name', 'license_number', 'specialization', 'hospital', 'department', 'status']
|
list_display = ['__str__', 'staff_type', 'job_title', 'employee_id', 'hospital', 'department', 'status']
|
||||||
list_filter = ['status', 'hospital', 'specialization']
|
list_filter = ['status', 'hospital', 'staff_type', 'specialization']
|
||||||
search_fields = ['first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'license_number']
|
search_fields = ['first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'employee_id', 'license_number', 'job_title']
|
||||||
ordering = ['last_name', 'first_name']
|
ordering = ['last_name', 'first_name']
|
||||||
autocomplete_fields = ['hospital', 'department', 'user']
|
autocomplete_fields = ['hospital', 'department', 'user']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
(None, {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
||||||
('Professional', {'fields': ('license_number', 'specialization')}),
|
('Role', {'fields': ('staff_type', 'job_title')}),
|
||||||
|
('Professional', {'fields': ('license_number', 'specialization', 'employee_id')}),
|
||||||
('Organization', {'fields': ('hospital', 'department')}),
|
('Organization', {'fields': ('hospital', 'department')}),
|
||||||
('Account', {'fields': ('user',)}),
|
('Account', {'fields': ('user',)}),
|
||||||
('Contact', {'fields': ('phone', 'email')}),
|
|
||||||
('Status', {'fields': ('status',)}),
|
('Status', {'fields': ('status',)}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital', 'department', 'user')
|
return qs.select_related('hospital', 'department', 'user')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Employee)
|
|
||||||
class EmployeeAdmin(admin.ModelAdmin):
|
|
||||||
"""Employee admin"""
|
|
||||||
list_display = ['user', 'employee_id', 'job_title', 'hospital', 'department', 'status']
|
|
||||||
list_filter = ['status', 'hospital', 'job_title']
|
|
||||||
search_fields = ['employee_id', 'job_title', 'user__first_name', 'user__last_name', 'user__email']
|
|
||||||
ordering = ['user__last_name', 'user__first_name']
|
|
||||||
autocomplete_fields = ['user', 'hospital', 'department']
|
|
||||||
|
|
||||||
fieldsets = (
|
|
||||||
(None, {'fields': ('user', 'employee_id', 'job_title')}),
|
|
||||||
('Organization', {'fields': ('hospital', 'department')}),
|
|
||||||
('Employment', {'fields': ('hire_date', 'status')}),
|
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
|
||||||
)
|
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
|
||||||
|
|
||||||
def get_queryset(self, request):
|
|
||||||
qs = super().get_queryset(request)
|
|
||||||
return qs.select_related('user', 'hospital', 'department')
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Patient)
|
@admin.register(Patient)
|
||||||
class PatientAdmin(admin.ModelAdmin):
|
class PatientAdmin(admin.ModelAdmin):
|
||||||
"""Patient admin"""
|
"""Patient admin"""
|
||||||
@ -105,7 +101,7 @@ class PatientAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ['mrn', 'national_id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'phone', 'email']
|
search_fields = ['mrn', 'national_id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'phone', 'email']
|
||||||
ordering = ['last_name', 'first_name']
|
ordering = ['last_name', 'first_name']
|
||||||
autocomplete_fields = ['primary_hospital']
|
autocomplete_fields = ['primary_hospital']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('mrn', 'national_id')}),
|
(None, {'fields': ('mrn', 'national_id')}),
|
||||||
('Personal Information', {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
('Personal Information', {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
||||||
@ -115,9 +111,9 @@ class PatientAdmin(admin.ModelAdmin):
|
|||||||
('Status', {'fields': ('status',)}),
|
('Status', {'fields': ('status',)}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('primary_hospital')
|
return qs.select_related('primary_hospital')
|
||||||
|
|||||||
@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Management command to create a default organization and assign orphaned hospitals
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.organizations.models import Organization, Hospital
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Create a default organization and assign hospitals without organization'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--name',
|
||||||
|
type=str,
|
||||||
|
default='Default Healthcare Organization',
|
||||||
|
help='Name of the default organization to create'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--code',
|
||||||
|
type=str,
|
||||||
|
default='DEFAULT',
|
||||||
|
help='Code of the default organization'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--force',
|
||||||
|
action='store_true',
|
||||||
|
help='Force reassignment even if hospital already has an organization'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='Show what would be done without making changes'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
name = options['name']
|
||||||
|
code = options['code']
|
||||||
|
force = options['force']
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
|
||||||
|
self.stdout.write(f"\n{'='*60}")
|
||||||
|
self.stdout.write(f"Organization Assignment Script")
|
||||||
|
self.stdout.write(f"{'='*60}\n")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Get or create default organization
|
||||||
|
org, created = Organization.objects.get_or_create(
|
||||||
|
code=code,
|
||||||
|
defaults={
|
||||||
|
'name': name,
|
||||||
|
'name_ar': name, # Use same name for Arabic
|
||||||
|
'status': 'active'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"✓ Created organization: {org.name} ({org.code})")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"✓ Found existing organization: {org.name} ({org.code})")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find hospitals without organization
|
||||||
|
if force:
|
||||||
|
hospitals_to_assign = Hospital.objects.all()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"\nForce mode: Will assign ALL hospitals")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
hospitals_to_assign = Hospital.objects.filter(organization__isnull=True)
|
||||||
|
count = hospitals_to_assign.count()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"\nFound {count} hospitals without organization")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hospitals_to_assign.exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS("\n✓ All hospitals already have organizations assigned")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display hospitals to be assigned
|
||||||
|
self.stdout.write("\nHospitals to assign:")
|
||||||
|
for i, hospital in enumerate(hospitals_to_assign, 1):
|
||||||
|
org_name = hospital.organization.name if hospital.organization else "None"
|
||||||
|
self.stdout.write(
|
||||||
|
f" {i}. {hospital.name} (Code: {hospital.code}) - Current: {org_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write("\n" + "="*60)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("DRY RUN: No changes were made")
|
||||||
|
)
|
||||||
|
self.stdout.write("="*60 + "\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Confirm assignment
|
||||||
|
if not force:
|
||||||
|
confirm = input(f"\nAssign {hospitals_to_assign.count()} hospital(s) to '{org.name}'? (yes/no): ")
|
||||||
|
if confirm.lower() not in ['yes', 'y']:
|
||||||
|
self.stdout.write(self.style.ERROR("\n✓ Operation cancelled"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Assign hospitals to organization
|
||||||
|
count = hospitals_to_assign.update(organization=org)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"\n✓ Successfully assigned {count} hospital(s) to '{org.name}'")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
self.stdout.write("\n" + "="*60)
|
||||||
|
self.stdout.write("Summary:")
|
||||||
|
self.stdout.write(f" Organization: {org.name} ({org.code})")
|
||||||
|
self.stdout.write(f" Hospitals assigned: {count}")
|
||||||
|
self.stdout.write(f" Total hospitals in organization: {org.hospitals.count()}")
|
||||||
|
self.stdout.write("="*60 + "\n")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("\nDry run completed - no changes applied\n"))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS("\nOrganization assignment completed successfully!\n"))
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:07
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
@ -31,12 +31,37 @@ class Migration(migrations.Migration):
|
|||||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
|
('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)),
|
('license_number', models.CharField(blank=True, max_length=100)),
|
||||||
('capacity', models.IntegerField(blank=True, help_text='Bed capacity', null=True)),
|
('capacity', models.IntegerField(blank=True, help_text='Bed capacity', null=True)),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict, help_text='Hospital configuration settings')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name_plural': 'Hospitals',
|
'verbose_name_plural': 'Hospitals',
|
||||||
'ordering': ['name'],
|
'ordering': ['name'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Organization',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
|
||||||
|
('code', models.CharField(db_index=True, max_length=50, unique=True)),
|
||||||
|
('phone', models.CharField(blank=True, max_length=20)),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254)),
|
||||||
|
('address', models.TextField(blank=True)),
|
||||||
|
('city', models.CharField(blank=True, max_length=100)),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
|
||||||
|
('logo', models.ImageField(blank=True, null=True, upload_to='organizations/logos/')),
|
||||||
|
('website', models.URLField(blank=True)),
|
||||||
|
('license_number', models.CharField(blank=True, max_length=100)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Organization',
|
||||||
|
'verbose_name_plural': 'Organizations',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Department',
|
name='Department',
|
||||||
fields=[
|
fields=[
|
||||||
@ -59,23 +84,10 @@ class Migration(migrations.Migration):
|
|||||||
'unique_together': {('hospital', 'code')},
|
'unique_together': {('hospital', 'code')},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.AddField(
|
||||||
name='Employee',
|
model_name='hospital',
|
||||||
fields=[
|
name='organization',
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
field=models.ForeignKey(blank=True, help_text='Parent organization (null for backward compatibility)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hospitals', to='organizations.organization'),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('employee_id', models.CharField(db_index=True, max_length=50, unique=True)),
|
|
||||||
('job_title', models.CharField(max_length=200)),
|
|
||||||
('hire_date', models.DateField(blank=True, null=True)),
|
|
||||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
|
|
||||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employees', to='organizations.department')),
|
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='employee_profile', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employees', to='organizations.hospital')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['user__last_name', 'user__first_name'],
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Patient',
|
name='Patient',
|
||||||
@ -103,7 +115,7 @@ class Migration(migrations.Migration):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Physician',
|
name='Staff',
|
||||||
fields=[
|
fields=[
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
@ -112,17 +124,18 @@ class Migration(migrations.Migration):
|
|||||||
('last_name', models.CharField(max_length=100)),
|
('last_name', models.CharField(max_length=100)),
|
||||||
('first_name_ar', models.CharField(blank=True, max_length=100)),
|
('first_name_ar', models.CharField(blank=True, max_length=100)),
|
||||||
('last_name_ar', models.CharField(blank=True, max_length=100)),
|
('last_name_ar', models.CharField(blank=True, max_length=100)),
|
||||||
('license_number', models.CharField(db_index=True, max_length=100, unique=True)),
|
('staff_type', models.CharField(choices=[('physician', 'Physician'), ('nurse', 'Nurse'), ('admin', 'Administrative'), ('other', 'Other')], max_length=20)),
|
||||||
('specialization', models.CharField(max_length=200)),
|
('job_title', models.CharField(max_length=200)),
|
||||||
('phone', models.CharField(blank=True, max_length=20)),
|
('license_number', models.CharField(blank=True, max_length=100, null=True, unique=True)),
|
||||||
('email', models.EmailField(blank=True, max_length=254)),
|
('specialization', models.CharField(blank=True, 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)),
|
('employee_id', models.CharField(db_index=True, max_length=50, unique=True)),
|
||||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='physicians', to='organizations.department')),
|
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='active', max_length=20)),
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='physicians', to='organizations.hospital')),
|
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='organizations.department')),
|
||||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='physician_profile', to=settings.AUTH_USER_MODEL)),
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff', to='organizations.hospital')),
|
||||||
|
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['last_name', 'first_name'],
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -6,8 +6,50 @@ from django.db import models
|
|||||||
from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices
|
from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices
|
||||||
|
|
||||||
|
|
||||||
|
class Organization(UUIDModel, TimeStampedModel):
|
||||||
|
"""Top-level healthcare organization/company"""
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
||||||
|
code = models.CharField(max_length=50, unique=True, db_index=True)
|
||||||
|
|
||||||
|
# Contact information
|
||||||
|
phone = models.CharField(max_length=20, blank=True)
|
||||||
|
email = models.EmailField(blank=True)
|
||||||
|
address = models.TextField(blank=True)
|
||||||
|
city = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=StatusChoices.choices,
|
||||||
|
default=StatusChoices.ACTIVE,
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Branding and metadata
|
||||||
|
logo = models.ImageField(upload_to='organizations/logos/', null=True, blank=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
license_number = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
verbose_name = 'Organization'
|
||||||
|
verbose_name_plural = 'Organizations'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Hospital(UUIDModel, TimeStampedModel):
|
class Hospital(UUIDModel, TimeStampedModel):
|
||||||
"""Hospital/Facility model"""
|
"""Hospital/Facility model"""
|
||||||
|
organization = models.ForeignKey(
|
||||||
|
Organization,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='hospitals',
|
||||||
|
help_text="Parent organization (null for backward compatibility)"
|
||||||
|
)
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
||||||
code = models.CharField(max_length=50, unique=True, db_index=True)
|
code = models.CharField(max_length=50, unique=True, db_index=True)
|
||||||
@ -29,6 +71,7 @@ class Hospital(UUIDModel, TimeStampedModel):
|
|||||||
# Metadata
|
# Metadata
|
||||||
license_number = models.CharField(max_length=100, blank=True)
|
license_number = models.CharField(max_length=100, blank=True)
|
||||||
capacity = models.IntegerField(null=True, blank=True, help_text="Bed capacity")
|
capacity = models.IntegerField(null=True, blank=True, help_text="Bed capacity")
|
||||||
|
metadata = models.JSONField(default=dict, blank=True, help_text="Hospital configuration settings")
|
||||||
|
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
@ -38,8 +81,11 @@ class Hospital(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
# TODO: Add branch
|
# TODO: Add branch
|
||||||
|
=======
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
|
|
||||||
class Department(UUIDModel, TimeStampedModel):
|
class Department(UUIDModel, TimeStampedModel):
|
||||||
"""Department within a hospital"""
|
"""Department within a hospital"""
|
||||||
@ -87,96 +133,137 @@ class Department(UUIDModel, TimeStampedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.hospital.name} - {self.name}"
|
return f"{self.hospital.name} - {self.name}"
|
||||||
|
|
||||||
# TODO Add Section
|
|
||||||
class Physician(UUIDModel, TimeStampedModel):
|
class Staff(UUIDModel, TimeStampedModel):
|
||||||
"""Physician/Doctor model"""
|
class StaffType(models.TextChoices):
|
||||||
# Link to user account (optional - some physicians may not have system access)
|
PHYSICIAN = 'physician', 'Physician'
|
||||||
|
NURSE = 'nurse', 'Nurse'
|
||||||
|
ADMIN = 'admin', 'Administrative'
|
||||||
|
OTHER = 'other', 'Other'
|
||||||
|
|
||||||
|
# Link to User (Keep it optional for external/temp staff)
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
'accounts.User',
|
'accounts.User',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True, blank=True,
|
||||||
blank=True,
|
related_name='staff_profile'
|
||||||
related_name='physician_profile'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Basic information
|
# Unified Identity (AI will search these 4 fields)
|
||||||
first_name = models.CharField(max_length=100)
|
first_name = models.CharField(max_length=100)
|
||||||
last_name = models.CharField(max_length=100)
|
last_name = models.CharField(max_length=100)
|
||||||
first_name_ar = models.CharField(max_length=100, blank=True)
|
first_name_ar = models.CharField(max_length=100, blank=True)
|
||||||
last_name_ar = models.CharField(max_length=100, blank=True)
|
last_name_ar = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
# Professional information
|
# Role Logic
|
||||||
license_number = models.CharField(max_length=100, unique=True, db_index=True)
|
staff_type = models.CharField(max_length=20, choices=StaffType.choices)
|
||||||
specialization = models.CharField(max_length=200)
|
job_title = models.CharField(max_length=200) # "Cardiologist", "Senior Nurse", etc.
|
||||||
|
|
||||||
# Organization
|
# Professional Data (Nullable for non-physicians)
|
||||||
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='physicians')
|
license_number = models.CharField(max_length=100, unique=True, null=True, blank=True)
|
||||||
department = models.ForeignKey(
|
specialization = models.CharField(max_length=200, blank=True)
|
||||||
Department,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='physicians'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Contact
|
|
||||||
phone = models.CharField(max_length=20, blank=True)
|
|
||||||
email = models.EmailField(blank=True)
|
|
||||||
|
|
||||||
# Status
|
|
||||||
status = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=StatusChoices.choices,
|
|
||||||
default=StatusChoices.ACTIVE,
|
|
||||||
db_index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['last_name', 'first_name']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Dr. {self.first_name} {self.last_name}"
|
|
||||||
|
|
||||||
def get_full_name(self):
|
|
||||||
return f"{self.first_name} {self.last_name}"
|
|
||||||
|
|
||||||
|
|
||||||
class Employee(UUIDModel, TimeStampedModel):
|
|
||||||
"""Employee model (non-physician staff)"""
|
|
||||||
user = models.OneToOneField(
|
|
||||||
'accounts.User',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='employee_profile'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Organization
|
|
||||||
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='employees')
|
|
||||||
department = models.ForeignKey(
|
|
||||||
Department,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='employees'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Job information
|
|
||||||
employee_id = models.CharField(max_length=50, unique=True, db_index=True)
|
employee_id = models.CharField(max_length=50, unique=True, db_index=True)
|
||||||
job_title = models.CharField(max_length=200)
|
|
||||||
hire_date = models.DateField(null=True, blank=True)
|
|
||||||
|
|
||||||
# Status
|
# Organization
|
||||||
status = models.CharField(
|
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='staff')
|
||||||
max_length=20,
|
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
choices=StatusChoices.choices,
|
|
||||||
default=StatusChoices.ACTIVE,
|
|
||||||
db_index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE)
|
||||||
ordering = ['user__last_name', 'user__first_name']
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.get_full_name()} - {self.job_title}"
|
prefix = "Dr. " if self.staff_type == self.StaffType.PHYSICIAN else ""
|
||||||
|
return f"{prefix}{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
# TODO Add Section
|
||||||
|
# class Physician(UUIDModel, TimeStampedModel):
|
||||||
|
# """Physician/Doctor model"""
|
||||||
|
# # Link to user account (optional - some physicians may not have system access)
|
||||||
|
# user = models.OneToOneField(
|
||||||
|
# 'accounts.User',
|
||||||
|
# on_delete=models.SET_NULL,
|
||||||
|
# null=True,
|
||||||
|
# blank=True,
|
||||||
|
# related_name='physician_profile'
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Basic information
|
||||||
|
# first_name = models.CharField(max_length=100)
|
||||||
|
# last_name = models.CharField(max_length=100)
|
||||||
|
# first_name_ar = models.CharField(max_length=100, blank=True)
|
||||||
|
# last_name_ar = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
# # Professional information
|
||||||
|
# license_number = models.CharField(max_length=100, unique=True, db_index=True)
|
||||||
|
# specialization = models.CharField(max_length=200)
|
||||||
|
|
||||||
|
# # Organization
|
||||||
|
# hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='physicians')
|
||||||
|
# department = models.ForeignKey(
|
||||||
|
# Department,
|
||||||
|
# on_delete=models.SET_NULL,
|
||||||
|
# null=True,
|
||||||
|
# blank=True,
|
||||||
|
# related_name='physicians'
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Contact
|
||||||
|
# phone = models.CharField(max_length=20, blank=True)
|
||||||
|
# email = models.EmailField(blank=True)
|
||||||
|
|
||||||
|
# # Status
|
||||||
|
# status = models.CharField(
|
||||||
|
# max_length=20,
|
||||||
|
# choices=StatusChoices.choices,
|
||||||
|
# default=StatusChoices.ACTIVE,
|
||||||
|
# db_index=True
|
||||||
|
# )
|
||||||
|
|
||||||
|
# class Meta:
|
||||||
|
# ordering = ['last_name', 'first_name']
|
||||||
|
|
||||||
|
# def __str__(self):
|
||||||
|
# return f"Dr. {self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
# def get_full_name(self):
|
||||||
|
# return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
|
||||||
|
# class Employee(UUIDModel, TimeStampedModel):
|
||||||
|
# """Employee model (non-physician staff)"""
|
||||||
|
# user = models.OneToOneField(
|
||||||
|
# 'accounts.User',
|
||||||
|
# on_delete=models.CASCADE,
|
||||||
|
# related_name='employee_profile'
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Organization
|
||||||
|
# hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='employees')
|
||||||
|
# department = models.ForeignKey(
|
||||||
|
# Department,
|
||||||
|
# on_delete=models.SET_NULL,
|
||||||
|
# null=True,
|
||||||
|
# blank=True,
|
||||||
|
# related_name='employees'
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Job information
|
||||||
|
# employee_id = models.CharField(max_length=50, unique=True, db_index=True)
|
||||||
|
# job_title = models.CharField(max_length=200)
|
||||||
|
# hire_date = models.DateField(null=True, blank=True)
|
||||||
|
|
||||||
|
# # Status
|
||||||
|
# status = models.CharField(
|
||||||
|
# max_length=20,
|
||||||
|
# choices=StatusChoices.choices,
|
||||||
|
# default=StatusChoices.ACTIVE,
|
||||||
|
# db_index=True
|
||||||
|
# )
|
||||||
|
|
||||||
|
# class Meta:
|
||||||
|
# ordering = ['user__last_name', 'user__first_name']
|
||||||
|
|
||||||
|
# def __str__(self):
|
||||||
|
# return f"{self.user.get_full_name()} - {self.job_title}"
|
||||||
|
|
||||||
|
|
||||||
class Patient(UUIDModel, TimeStampedModel):
|
class Patient(UUIDModel, TimeStampedModel):
|
||||||
@ -229,3 +316,27 @@ class Patient(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
def get_full_name(self):
|
def get_full_name(self):
|
||||||
return f"{self.first_name} {self.last_name}"
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_mrn():
|
||||||
|
"""
|
||||||
|
Generate a unique Medical Record Number (MRN).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A unique MRN in the format: PTN-YYYYMMDD-XXXXXX
|
||||||
|
where XXXXXX is a random 6-digit number
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Generate MRN with date prefix for better traceability
|
||||||
|
date_prefix = datetime.now().strftime('%Y%m%d')
|
||||||
|
random_suffix = random.randint(100000, 999999)
|
||||||
|
mrn = f"PTN-{date_prefix}-{random_suffix}"
|
||||||
|
|
||||||
|
# Ensure uniqueness (in case of collision)
|
||||||
|
while Patient.objects.filter(mrn=mrn).exists():
|
||||||
|
random_suffix = random.randint(100000, 999999)
|
||||||
|
mrn = f"PTN-{date_prefix}-{random_suffix}"
|
||||||
|
|
||||||
|
return mrn
|
||||||
|
|||||||
@ -3,28 +3,53 @@ Organizations serializers
|
|||||||
"""
|
"""
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import Department, Employee, Hospital, Patient, Physician
|
from .models import Department, Hospital, Organization, Patient, Staff
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationSerializer(serializers.ModelSerializer):
|
||||||
|
"""Organization serializer"""
|
||||||
|
hospitals_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Organization
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'name_ar', 'code', 'address', 'city',
|
||||||
|
'phone', 'email', 'website', 'status', 'license_number',
|
||||||
|
'logo', 'hospitals_count', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_hospitals_count(self, obj):
|
||||||
|
"""Get count of hospitals in this organization"""
|
||||||
|
return obj.hospitals.count()
|
||||||
|
|
||||||
|
|
||||||
class HospitalSerializer(serializers.ModelSerializer):
|
class HospitalSerializer(serializers.ModelSerializer):
|
||||||
"""Hospital serializer"""
|
"""Hospital serializer"""
|
||||||
|
organization_name = serializers.CharField(source='organization.name', read_only=True)
|
||||||
|
departments_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Hospital
|
model = Hospital
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'name_ar', 'code', 'address', 'city',
|
'id', 'organization', 'organization_name', 'name', 'name_ar', 'code',
|
||||||
'phone', 'email', 'status', 'license_number', 'capacity',
|
'address', 'city', 'phone', 'email', 'status',
|
||||||
|
'license_number', 'capacity', 'departments_count',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_departments_count(self, obj):
|
||||||
|
"""Get count of departments in this hospital"""
|
||||||
|
return obj.departments.count()
|
||||||
|
|
||||||
|
|
||||||
class DepartmentSerializer(serializers.ModelSerializer):
|
class DepartmentSerializer(serializers.ModelSerializer):
|
||||||
"""Department serializer"""
|
"""Department serializer"""
|
||||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||||
parent_name = serializers.CharField(source='parent.name', read_only=True)
|
parent_name = serializers.CharField(source='parent.name', read_only=True)
|
||||||
manager_name = serializers.SerializerMethodField()
|
manager_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Department
|
model = Department
|
||||||
fields = [
|
fields = [
|
||||||
@ -34,7 +59,7 @@ class DepartmentSerializer(serializers.ModelSerializer):
|
|||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_manager_name(self, obj):
|
def get_manager_name(self, obj):
|
||||||
"""Get manager full name"""
|
"""Get manager full name"""
|
||||||
if obj.manager:
|
if obj.manager:
|
||||||
@ -42,52 +67,32 @@ class DepartmentSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class PhysicianSerializer(serializers.ModelSerializer):
|
class StaffSerializer(serializers.ModelSerializer):
|
||||||
"""Physician serializer"""
|
"""Staff serializer"""
|
||||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||||
department_name = serializers.CharField(source='department.name', read_only=True)
|
department_name = serializers.CharField(source='department.name', read_only=True)
|
||||||
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||||
|
user_email = serializers.EmailField(source='user.email', read_only=True, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Physician
|
model = Staff
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'user', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
|
'id', 'user', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
|
||||||
'full_name', 'license_number', 'specialization',
|
'full_name', 'staff_type', 'job_title',
|
||||||
|
'license_number', 'specialization', 'employee_id',
|
||||||
'hospital', 'hospital_name', 'department', 'department_name',
|
'hospital', 'hospital_name', 'department', 'department_name',
|
||||||
'phone', 'email', 'status',
|
'user_email', 'status',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
class EmployeeSerializer(serializers.ModelSerializer):
|
|
||||||
"""Employee serializer"""
|
|
||||||
user_email = serializers.EmailField(source='user.email', read_only=True)
|
|
||||||
user_name = serializers.SerializerMethodField()
|
|
||||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
|
||||||
department_name = serializers.CharField(source='department.name', read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Employee
|
|
||||||
fields = [
|
|
||||||
'id', 'user', 'user_email', 'user_name', 'employee_id', 'job_title',
|
|
||||||
'hospital', 'hospital_name', 'department', 'department_name',
|
|
||||||
'hire_date', 'status',
|
|
||||||
'created_at', 'updated_at'
|
|
||||||
]
|
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
|
||||||
|
|
||||||
def get_user_name(self, obj):
|
|
||||||
"""Get user full name"""
|
|
||||||
return obj.user.get_full_name()
|
|
||||||
|
|
||||||
|
|
||||||
class PatientSerializer(serializers.ModelSerializer):
|
class PatientSerializer(serializers.ModelSerializer):
|
||||||
"""Patient serializer"""
|
"""Patient serializer"""
|
||||||
primary_hospital_name = serializers.CharField(source='primary_hospital.name', read_only=True)
|
primary_hospital_name = serializers.CharField(source='primary_hospital.name', read_only=True)
|
||||||
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||||
age = serializers.SerializerMethodField()
|
age = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Patient
|
model = Patient
|
||||||
fields = [
|
fields = [
|
||||||
@ -99,7 +104,7 @@ class PatientSerializer(serializers.ModelSerializer):
|
|||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_age(self, obj):
|
def get_age(self, obj):
|
||||||
"""Calculate patient age"""
|
"""Calculate patient age"""
|
||||||
if obj.date_of_birth:
|
if obj.date_of_birth:
|
||||||
@ -115,7 +120,7 @@ class PatientListSerializer(serializers.ModelSerializer):
|
|||||||
"""Simplified patient serializer for list views"""
|
"""Simplified patient serializer for list views"""
|
||||||
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||||
primary_hospital_name = serializers.CharField(source='primary_hospital.name', read_only=True)
|
primary_hospital_name = serializers.CharField(source='primary_hospital.name', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Patient
|
model = Patient
|
||||||
fields = [
|
fields = [
|
||||||
|
|||||||
@ -1,29 +1,26 @@
|
|||||||
"""
|
|
||||||
Organizations Console UI views
|
|
||||||
"""
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
from .models import Department, Hospital, Patient, Physician
|
from .models import Department, Hospital, Organization, Patient, Staff
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def hospital_list(request):
|
def hospital_list(request):
|
||||||
"""Hospitals list view"""
|
"""Hospitals list view"""
|
||||||
queryset = Hospital.objects.all()
|
queryset = Hospital.objects.all()
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(id=user.hospital.id)
|
queryset = queryset.filter(id=user.hospital.id)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
status_filter = request.GET.get('status')
|
status_filter = request.GET.get('status')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
@ -32,22 +29,22 @@ def hospital_list(request):
|
|||||||
Q(name_ar__icontains=search_query) |
|
Q(name_ar__icontains=search_query) |
|
||||||
Q(code__icontains=search_query)
|
Q(code__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
queryset = queryset.order_by('name')
|
queryset = queryset.order_by('name')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'hospitals': page_obj.object_list,
|
'hospitals': page_obj.object_list,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'organizations/hospital_list.html', context)
|
return render(request, 'organizations/hospital_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -55,21 +52,21 @@ def hospital_list(request):
|
|||||||
def department_list(request):
|
def department_list(request):
|
||||||
"""Departments list view"""
|
"""Departments list view"""
|
||||||
queryset = Department.objects.select_related('hospital', 'manager')
|
queryset = Department.objects.select_related('hospital', 'manager')
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(hospital=user.hospital)
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
status_filter = request.GET.get('status')
|
status_filter = request.GET.get('status')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
@ -78,107 +75,229 @@ def department_list(request):
|
|||||||
Q(name_ar__icontains=search_query) |
|
Q(name_ar__icontains=search_query) |
|
||||||
Q(code__icontains=search_query)
|
Q(code__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
queryset = queryset.order_by('hospital', 'name')
|
queryset = queryset.order_by('hospital', 'name')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get hospitals for filter
|
# Get hospitals for filter
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'departments': page_obj.object_list,
|
'departments': page_obj.object_list,
|
||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'organizations/department_list.html', context)
|
return render(request, 'organizations/department_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def physician_list(request):
|
def staff_list(request):
|
||||||
"""Physicians list view"""
|
"""Staff list view"""
|
||||||
queryset = Physician.objects.select_related('hospital', 'department', 'user')
|
queryset = Staff.objects.select_related('hospital', 'department', 'user')
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(hospital=user.hospital)
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
department_filter = request.GET.get('department')
|
department_filter = request.GET.get('department')
|
||||||
if department_filter:
|
if department_filter:
|
||||||
queryset = queryset.filter(department_id=department_filter)
|
queryset = queryset.filter(department_id=department_filter)
|
||||||
|
|
||||||
status_filter = request.GET.get('status')
|
status_filter = request.GET.get('status')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
|
staff_type_filter = request.GET.get('staff_type')
|
||||||
|
if staff_type_filter:
|
||||||
|
queryset = queryset.filter(staff_type=staff_type_filter)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(first_name__icontains=search_query) |
|
Q(first_name__icontains=search_query) |
|
||||||
Q(last_name__icontains=search_query) |
|
Q(last_name__icontains=search_query) |
|
||||||
|
Q(employee_id__icontains=search_query) |
|
||||||
Q(license_number__icontains=search_query) |
|
Q(license_number__icontains=search_query) |
|
||||||
Q(specialization__icontains=search_query)
|
Q(specialization__icontains=search_query) |
|
||||||
|
Q(job_title__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
queryset = queryset.order_by('last_name', 'first_name')
|
queryset = queryset.order_by('last_name', 'first_name')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get hospitals for filter
|
# Get hospitals for filter
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'physicians': page_obj.object_list,
|
'staff': page_obj.object_list,
|
||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'organizations/physician_list.html', context)
|
return render(request, 'organizations/staff_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def organization_list(request):
|
||||||
|
"""Organizations list view"""
|
||||||
|
queryset = Organization.objects.all()
|
||||||
|
|
||||||
|
# Apply RBAC filters
|
||||||
|
user = request.user
|
||||||
|
if not user.is_px_admin() and user.hospital and user.hospital.organization:
|
||||||
|
queryset = queryset.filter(id=user.hospital.organization.id)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
status_filter = request.GET.get('status')
|
||||||
|
if status_filter:
|
||||||
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
|
city_filter = request.GET.get('city')
|
||||||
|
if city_filter:
|
||||||
|
queryset = queryset.filter(city__icontains=city_filter)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search_query = request.GET.get('search')
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(name__icontains=search_query) |
|
||||||
|
Q(name_ar__icontains=search_query) |
|
||||||
|
Q(code__icontains=search_query) |
|
||||||
|
Q(license_number__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordering
|
||||||
|
queryset = queryset.order_by('name')
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
|
paginator = Paginator(queryset, page_size)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'organizations': page_obj.object_list,
|
||||||
|
'filters': request.GET,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'organizations/organization_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def organization_detail(request, pk):
|
||||||
|
"""Organization detail view"""
|
||||||
|
organization = Organization.objects.get(pk=pk)
|
||||||
|
|
||||||
|
# Apply RBAC filters
|
||||||
|
user = request.user
|
||||||
|
if not user.is_px_admin():
|
||||||
|
if user.hospital and user.hospital.organization:
|
||||||
|
if organization.id != user.hospital.organization.id:
|
||||||
|
# User doesn't have access to this organization
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
return HttpResponseForbidden("You don't have permission to view this organization")
|
||||||
|
else:
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
return HttpResponseForbidden("You don't have permission to view this organization")
|
||||||
|
|
||||||
|
hospitals = organization.hospitals.all()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'organization': organization,
|
||||||
|
'hospitals': hospitals,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'organizations/organization_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def organization_create(request):
|
||||||
|
"""Create organization view"""
|
||||||
|
# Only PX Admins can create organizations
|
||||||
|
user = request.user
|
||||||
|
if not user.is_px_admin():
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
return HttpResponseForbidden("Only PX Admins can create organizations")
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.POST.get('name')
|
||||||
|
name_ar = request.POST.get('name_ar')
|
||||||
|
code = request.POST.get('code')
|
||||||
|
address = request.POST.get('address', '')
|
||||||
|
city = request.POST.get('city', '')
|
||||||
|
phone = request.POST.get('phone', '')
|
||||||
|
email = request.POST.get('email', '')
|
||||||
|
website = request.POST.get('website', '')
|
||||||
|
license_number = request.POST.get('license_number', '')
|
||||||
|
status = request.POST.get('status', 'active')
|
||||||
|
|
||||||
|
if name and code:
|
||||||
|
organization = Organization.objects.create(
|
||||||
|
name=name,
|
||||||
|
name_ar=name_ar or name,
|
||||||
|
code=code,
|
||||||
|
address=address,
|
||||||
|
city=city,
|
||||||
|
phone=phone,
|
||||||
|
email=email,
|
||||||
|
website=website,
|
||||||
|
license_number=license_number,
|
||||||
|
status=status
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect to organization detail
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
return redirect('organizations:organization_detail', pk=organization.id)
|
||||||
|
|
||||||
|
return render(request, 'organizations/organization_form.html')
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def patient_list(request):
|
def patient_list(request):
|
||||||
"""Patients list view"""
|
"""Patients list view"""
|
||||||
queryset = Patient.objects.select_related('primary_hospital')
|
queryset = Patient.objects.select_related('primary_hospital')
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(primary_hospital=user.hospital)
|
queryset = queryset.filter(primary_hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(primary_hospital_id=hospital_filter)
|
queryset = queryset.filter(primary_hospital_id=hospital_filter)
|
||||||
|
|
||||||
status_filter = request.GET.get('status')
|
status_filter = request.GET.get('status')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
@ -189,26 +308,26 @@ def patient_list(request):
|
|||||||
Q(national_id__icontains=search_query) |
|
Q(national_id__icontains=search_query) |
|
||||||
Q(phone__icontains=search_query)
|
Q(phone__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
queryset = queryset.order_by('last_name', 'first_name')
|
queryset = queryset.order_by('last_name', 'first_name')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get hospitals for filter
|
# Get hospitals for filter
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'patients': page_obj.object_list,
|
'patients': page_obj.object_list,
|
||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'organizations/patient_list.html', context)
|
return render(request, 'organizations/patient_list.html', context)
|
||||||
|
|||||||
@ -1,25 +1,34 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from .views import DepartmentViewSet, EmployeeViewSet, HospitalViewSet, PatientViewSet, PhysicianViewSet
|
from .views import (
|
||||||
|
DepartmentViewSet,
|
||||||
|
HospitalViewSet,
|
||||||
|
OrganizationViewSet,
|
||||||
|
PatientViewSet,
|
||||||
|
StaffViewSet,
|
||||||
|
)
|
||||||
from . import ui_views
|
from . import ui_views
|
||||||
|
|
||||||
app_name = 'organizations'
|
app_name = 'organizations'
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
|
router.register(r'api/organizations', OrganizationViewSet, basename='organization-api')
|
||||||
router.register(r'api/hospitals', HospitalViewSet, basename='hospital-api')
|
router.register(r'api/hospitals', HospitalViewSet, basename='hospital-api')
|
||||||
router.register(r'api/departments', DepartmentViewSet, basename='department-api')
|
router.register(r'api/departments', DepartmentViewSet, basename='department-api')
|
||||||
router.register(r'api/physicians', PhysicianViewSet, basename='physician-api')
|
router.register(r'api/staff', StaffViewSet, basename='staff-api')
|
||||||
router.register(r'api/employees', EmployeeViewSet, basename='employee-api')
|
|
||||||
router.register(r'api/patients', PatientViewSet, basename='patient-api')
|
router.register(r'api/patients', PatientViewSet, basename='patient-api')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# UI Views
|
# UI Views
|
||||||
|
path('organizations/', ui_views.organization_list, name='organization_list'),
|
||||||
|
path('organizations/create/', ui_views.organization_create, name='organization_create'),
|
||||||
|
path('organizations/<uuid:pk>/', ui_views.organization_detail, name='organization_detail'),
|
||||||
path('hospitals/', ui_views.hospital_list, name='hospital_list'),
|
path('hospitals/', ui_views.hospital_list, name='hospital_list'),
|
||||||
path('departments/', ui_views.department_list, name='department_list'),
|
path('departments/', ui_views.department_list, name='department_list'),
|
||||||
path('physicians/', ui_views.physician_list, name='physician_list'),
|
path('staff/', ui_views.staff_list, name='staff_list'),
|
||||||
path('patients/', ui_views.patient_list, name='patient_list'),
|
path('patients/', ui_views.patient_list, name='patient_list'),
|
||||||
|
|
||||||
# API Routes
|
# API Routes
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,26 +1,64 @@
|
|||||||
"""
|
"""
|
||||||
Organizations views and viewsets
|
Organizations views and viewsets
|
||||||
"""
|
"""
|
||||||
|
from django.db import models
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
from apps.accounts.permissions import CanAccessDepartmentData, CanAccessHospitalData, IsPXAdminOrHospitalAdmin
|
from apps.accounts.permissions import CanAccessDepartmentData, CanAccessHospitalData, IsPXAdminOrHospitalAdmin
|
||||||
|
|
||||||
from .models import Department, Employee, Hospital, Patient, Physician
|
from .models import Department, Hospital, Organization, Patient, Staff
|
||||||
|
from .models import Staff as StaffModel
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
DepartmentSerializer,
|
DepartmentSerializer,
|
||||||
EmployeeSerializer,
|
|
||||||
HospitalSerializer,
|
HospitalSerializer,
|
||||||
|
OrganizationSerializer,
|
||||||
PatientListSerializer,
|
PatientListSerializer,
|
||||||
PatientSerializer,
|
PatientSerializer,
|
||||||
PhysicianSerializer,
|
StaffSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for Organization model.
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- PX Admins can manage organizations
|
||||||
|
- Others can view organizations
|
||||||
|
"""
|
||||||
|
queryset = Organization.objects.all()
|
||||||
|
serializer_class = OrganizationSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filterset_fields = ['status', 'city']
|
||||||
|
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
||||||
|
ordering_fields = ['name', 'created_at']
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter organizations based on user role"""
|
||||||
|
queryset = super().get_queryset().prefetch_related('hospitals')
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
# PX Admins see all organizations
|
||||||
|
if user.is_px_admin():
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
# Hospital Admins and others see their organization
|
||||||
|
if user.is_hospital_admin() and user.hospital and user.hospital.organization:
|
||||||
|
return queryset.filter(id=user.hospital.organization.id)
|
||||||
|
|
||||||
|
# Others with hospital see their organization
|
||||||
|
if user.hospital and user.hospital.organization:
|
||||||
|
return queryset.filter(id=user.hospital.organization.id)
|
||||||
|
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class HospitalViewSet(viewsets.ModelViewSet):
|
class HospitalViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Hospital model.
|
ViewSet for Hospital model.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- PX Admins and Hospital Admins can manage hospitals
|
- PX Admins and Hospital Admins can manage hospitals
|
||||||
- Others can view hospitals they belong to
|
- Others can view hospitals they belong to
|
||||||
@ -28,39 +66,39 @@ class HospitalViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = Hospital.objects.all()
|
queryset = Hospital.objects.all()
|
||||||
serializer_class = HospitalSerializer
|
serializer_class = HospitalSerializer
|
||||||
permission_classes = [IsAuthenticated, CanAccessHospitalData]
|
permission_classes = [IsAuthenticated, CanAccessHospitalData]
|
||||||
filterset_fields = ['status', 'city']
|
filterset_fields = ['status', 'city', 'organization']
|
||||||
search_fields = ['name', 'name_ar', 'code', 'city']
|
search_fields = ['name', 'name_ar', 'code', 'city']
|
||||||
ordering_fields = ['name', 'created_at']
|
ordering_fields = ['name', 'created_at']
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter hospitals based on user role"""
|
"""Filter hospitals based on user role"""
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all hospitals
|
# PX Admins see all hospitals
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see their hospital
|
# Hospital Admins see their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(id=user.hospital.id)
|
return queryset.filter(id=user.hospital.id)
|
||||||
|
|
||||||
# Department Managers see their hospital
|
# Department Managers see their hospital
|
||||||
if user.is_department_manager() and user.hospital:
|
if user.is_department_manager() and user.hospital:
|
||||||
return queryset.filter(id=user.hospital.id)
|
return queryset.filter(id=user.hospital.id)
|
||||||
|
|
||||||
# Others see hospitals they're associated with
|
# Others see hospitals they're associated with
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(id=user.hospital.id)
|
return queryset.filter(id=user.hospital.id)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class DepartmentViewSet(viewsets.ModelViewSet):
|
class DepartmentViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Department model.
|
ViewSet for Department model.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- PX Admins and Hospital Admins can manage departments
|
- PX Admins and Hospital Admins can manage departments
|
||||||
- Department Managers can view their department
|
- Department Managers can view their department
|
||||||
@ -68,24 +106,24 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = Department.objects.all()
|
queryset = Department.objects.all()
|
||||||
serializer_class = DepartmentSerializer
|
serializer_class = DepartmentSerializer
|
||||||
permission_classes = [IsAuthenticated, CanAccessDepartmentData]
|
permission_classes = [IsAuthenticated, CanAccessDepartmentData]
|
||||||
filterset_fields = ['status', 'hospital', 'parent']
|
filterset_fields = ['status', 'hospital', 'parent', 'hospital__organization']
|
||||||
search_fields = ['name', 'name_ar', 'code']
|
search_fields = ['name', 'name_ar', 'code']
|
||||||
ordering_fields = ['name', 'created_at']
|
ordering_fields = ['name', 'created_at']
|
||||||
ordering = ['hospital', 'name']
|
ordering = ['hospital', 'name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter departments based on user role"""
|
"""Filter departments based on user role"""
|
||||||
queryset = super().get_queryset().select_related('hospital', 'parent', 'manager')
|
queryset = super().get_queryset().select_related('hospital', 'parent', 'manager')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all departments
|
# PX Admins see all departments
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see departments in their hospital
|
# Hospital Admins see departments in their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Department Managers see their department and sub-departments
|
# Department Managers see their department and sub-departments
|
||||||
if user.is_department_manager() and user.department:
|
if user.is_department_manager() and user.department:
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
@ -93,130 +131,90 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
|||||||
).filter(
|
).filter(
|
||||||
models.Q(id=user.department.id) | models.Q(parent=user.department)
|
models.Q(id=user.department.id) | models.Q(parent=user.department)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Others see departments in their hospital
|
# Others see departments in their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class PhysicianViewSet(viewsets.ModelViewSet):
|
class StaffViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Physician model.
|
ViewSet for Staff model.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- PX Admins and Hospital Admins can manage physicians
|
- PX Admins and Hospital Admins can manage staff
|
||||||
- Others can view physicians
|
- Others can view staff
|
||||||
"""
|
"""
|
||||||
queryset = Physician.objects.all()
|
queryset = StaffModel.objects.all()
|
||||||
serializer_class = PhysicianSerializer
|
serializer_class = StaffSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['status', 'hospital', 'department', 'specialization']
|
filterset_fields = ['status', 'hospital', 'department', 'staff_type', 'specialization', 'job_title', 'hospital__organization']
|
||||||
search_fields = ['first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'license_number']
|
search_fields = ['first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'employee_id', 'license_number', 'job_title']
|
||||||
ordering_fields = ['last_name', 'created_at']
|
ordering_fields = ['last_name', 'created_at']
|
||||||
ordering = ['last_name', 'first_name']
|
ordering = ['last_name', 'first_name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter physicians based on user role"""
|
"""Filter staff based on user role"""
|
||||||
queryset = super().get_queryset().select_related('hospital', 'department', 'user')
|
queryset = super().get_queryset().select_related('hospital', 'department', 'user')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all physicians
|
# PX Admins see all staff
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see physicians in their hospital
|
# Hospital Admins see staff in their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Department Managers see physicians in their department
|
# Department Managers see staff in their department
|
||||||
if user.is_department_manager() and user.department:
|
if user.is_department_manager() and user.department:
|
||||||
return queryset.filter(department=user.department)
|
return queryset.filter(department=user.department)
|
||||||
|
|
||||||
# Others see physicians in their hospital
|
# Others see staff in their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
|
||||||
|
|
||||||
|
|
||||||
class EmployeeViewSet(viewsets.ModelViewSet):
|
|
||||||
"""
|
|
||||||
ViewSet for Employee model.
|
|
||||||
|
|
||||||
Permissions:
|
|
||||||
- PX Admins and Hospital Admins can manage employees
|
|
||||||
- Others can view employees
|
|
||||||
"""
|
|
||||||
queryset = Employee.objects.all()
|
|
||||||
serializer_class = EmployeeSerializer
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
filterset_fields = ['status', 'hospital', 'department', 'job_title']
|
|
||||||
search_fields = ['employee_id', 'job_title', 'user__first_name', 'user__last_name', 'user__email']
|
|
||||||
ordering_fields = ['user__last_name', 'created_at']
|
|
||||||
ordering = ['user__last_name', 'user__first_name']
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
"""Filter employees based on user role"""
|
|
||||||
queryset = super().get_queryset().select_related('user', 'hospital', 'department')
|
|
||||||
user = self.request.user
|
|
||||||
|
|
||||||
# PX Admins see all employees
|
|
||||||
if user.is_px_admin():
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
# Hospital Admins see employees in their hospital
|
|
||||||
if user.is_hospital_admin() and user.hospital:
|
|
||||||
return queryset.filter(hospital=user.hospital)
|
|
||||||
|
|
||||||
# Department Managers see employees in their department
|
|
||||||
if user.is_department_manager() and user.department:
|
|
||||||
return queryset.filter(department=user.department)
|
|
||||||
|
|
||||||
# Others see employees in their hospital
|
|
||||||
if user.hospital:
|
|
||||||
return queryset.filter(hospital=user.hospital)
|
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class PatientViewSet(viewsets.ModelViewSet):
|
class PatientViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Patient model.
|
ViewSet for Patient model.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- All authenticated users can view patients
|
- All authenticated users can view patients
|
||||||
- PX Admins and Hospital Admins can manage patients
|
- PX Admins and Hospital Admins can manage patients
|
||||||
"""
|
"""
|
||||||
queryset = Patient.objects.all()
|
queryset = Patient.objects.all()
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['status', 'gender', 'primary_hospital', 'city']
|
filterset_fields = ['status', 'gender', 'primary_hospital', 'city', 'primary_hospital__organization']
|
||||||
search_fields = ['mrn', 'national_id', 'first_name', 'last_name', 'phone', 'email']
|
search_fields = ['mrn', 'national_id', 'first_name', 'last_name', 'phone', 'email']
|
||||||
ordering_fields = ['last_name', 'created_at']
|
ordering_fields = ['last_name', 'created_at']
|
||||||
ordering = ['last_name', 'first_name']
|
ordering = ['last_name', 'first_name']
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
"""Use simplified serializer for list view"""
|
"""Use simplified serializer for list view"""
|
||||||
if self.action == 'list':
|
if self.action == 'list':
|
||||||
return PatientListSerializer
|
return PatientListSerializer
|
||||||
return PatientSerializer
|
return PatientSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter patients based on user role"""
|
"""Filter patients based on user role"""
|
||||||
queryset = super().get_queryset().select_related('primary_hospital')
|
queryset = super().get_queryset().select_related('primary_hospital')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all patients
|
# PX Admins see all patients
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see patients in their hospital
|
# Hospital Admins see patients in their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(primary_hospital=user.hospital)
|
return queryset.filter(primary_hospital=user.hospital)
|
||||||
|
|
||||||
# Others see patients in their hospital
|
# Others see patients in their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(primary_hospital=user.hospital)
|
return queryset.filter(primary_hospital=user.hospital)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|||||||
@ -10,18 +10,18 @@ from .models import PhysicianMonthlyRating
|
|||||||
class PhysicianMonthlyRatingAdmin(admin.ModelAdmin):
|
class PhysicianMonthlyRatingAdmin(admin.ModelAdmin):
|
||||||
"""Physician monthly rating admin"""
|
"""Physician monthly rating admin"""
|
||||||
list_display = [
|
list_display = [
|
||||||
'physician', 'year', 'month', 'average_rating',
|
'staff', 'year', 'month', 'average_rating',
|
||||||
'total_surveys', 'hospital_rank', 'department_rank'
|
'total_surveys', 'hospital_rank', 'department_rank'
|
||||||
]
|
]
|
||||||
list_filter = ['year', 'month', 'physician__hospital', 'physician__department']
|
list_filter = ['year', 'month', 'staff__hospital', 'staff__department']
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'physician__first_name', 'physician__last_name', 'physician__license_number'
|
'staff__first_name', 'staff__last_name', 'staff__license_number'
|
||||||
]
|
]
|
||||||
ordering = ['-year', '-month', '-average_rating']
|
ordering = ['-year', '-month', '-average_rating']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Physician & Period', {
|
('Physician & Period', {
|
||||||
'fields': ('physician', 'year', 'month')
|
'fields': ('staff', 'year', 'month')
|
||||||
}),
|
}),
|
||||||
('Ratings', {
|
('Ratings', {
|
||||||
'fields': (
|
'fields': (
|
||||||
@ -40,9 +40,9 @@ class PhysicianMonthlyRatingAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('physician', 'physician__hospital', 'physician__department')
|
return qs.select_related('staff', 'staff__hospital', 'staff__department')
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 11:25
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
@ -31,12 +31,12 @@ class Migration(migrations.Migration):
|
|||||||
('hospital_rank', models.IntegerField(blank=True, help_text='Rank within hospital', 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)),
|
('department_rank', models.IntegerField(blank=True, help_text='Rank within department', null=True)),
|
||||||
('metadata', models.JSONField(blank=True, default=dict)),
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
('physician', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_ratings', to='organizations.physician')),
|
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_ratings', to='organizations.staff')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['-year', '-month', '-average_rating'],
|
'ordering': ['-year', '-month', '-average_rating'],
|
||||||
'indexes': [models.Index(fields=['physician', '-year', '-month'], name='physicians__physici_963ee5_idx'), models.Index(fields=['year', 'month', '-average_rating'], name='physicians__year_e38883_idx')],
|
'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': {('physician', 'year', 'month')},
|
'unique_together': {('staff', 'year', 'month')},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -14,19 +14,19 @@ from apps.core.models import TimeStampedModel, UUIDModel
|
|||||||
class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Physician monthly rating - aggregated from survey responses.
|
Physician monthly rating - aggregated from survey responses.
|
||||||
|
|
||||||
Calculated monthly from all surveys mentioning this physician.
|
Calculated monthly from all surveys mentioning this physician.
|
||||||
"""
|
"""
|
||||||
physician = models.ForeignKey(
|
staff = models.ForeignKey(
|
||||||
'organizations.Physician',
|
'organizations.Staff',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='monthly_ratings'
|
related_name='monthly_ratings'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Time period
|
# Time period
|
||||||
year = models.IntegerField(db_index=True)
|
year = models.IntegerField(db_index=True)
|
||||||
month = models.IntegerField(db_index=True, help_text="1-12")
|
month = models.IntegerField(db_index=True, help_text="1-12")
|
||||||
|
|
||||||
# Ratings
|
# Ratings
|
||||||
average_rating = models.DecimalField(
|
average_rating = models.DecimalField(
|
||||||
max_digits=3,
|
max_digits=3,
|
||||||
@ -39,7 +39,7 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
|||||||
positive_count = models.IntegerField(default=0)
|
positive_count = models.IntegerField(default=0)
|
||||||
neutral_count = models.IntegerField(default=0)
|
neutral_count = models.IntegerField(default=0)
|
||||||
negative_count = models.IntegerField(default=0)
|
negative_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
# Breakdown by journey stage
|
# Breakdown by journey stage
|
||||||
md_consult_rating = models.DecimalField(
|
md_consult_rating = models.DecimalField(
|
||||||
max_digits=3,
|
max_digits=3,
|
||||||
@ -47,7 +47,7 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
|||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ranking
|
# Ranking
|
||||||
hospital_rank = models.IntegerField(
|
hospital_rank = models.IntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
@ -59,17 +59,17 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Rank within department"
|
help_text="Rank within department"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-year', '-month', '-average_rating']
|
ordering = ['-year', '-month', '-average_rating']
|
||||||
unique_together = [['physician', 'year', 'month']]
|
unique_together = [['staff', 'year', 'month']]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['physician', '-year', '-month']),
|
models.Index(fields=['staff', '-year', '-month']),
|
||||||
models.Index(fields=['year', 'month', '-average_rating']),
|
models.Index(fields=['year', 'month', '-average_rating']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.physician.get_full_name()} - {self.year}-{self.month:02d}: {self.average_rating}"
|
return f"{self.staff.get_full_name()} - {self.year}-{self.month:02d}: {self.average_rating}"
|
||||||
|
|||||||
@ -3,7 +3,7 @@ Physicians serializers
|
|||||||
"""
|
"""
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from apps.organizations.models import Physician
|
from apps.organizations.models import Staff
|
||||||
|
|
||||||
from .models import PhysicianMonthlyRating
|
from .models import PhysicianMonthlyRating
|
||||||
|
|
||||||
@ -13,9 +13,9 @@ class PhysicianSerializer(serializers.ModelSerializer):
|
|||||||
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||||
department_name = serializers.CharField(source='department.name', read_only=True)
|
department_name = serializers.CharField(source='department.name', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Physician
|
model = Staff
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
|
'id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
|
||||||
'full_name', 'license_number', 'specialization',
|
'full_name', 'license_number', 'specialization',
|
||||||
@ -28,16 +28,16 @@ class PhysicianSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class PhysicianMonthlyRatingSerializer(serializers.ModelSerializer):
|
class PhysicianMonthlyRatingSerializer(serializers.ModelSerializer):
|
||||||
"""Physician monthly rating serializer"""
|
"""Physician monthly rating serializer"""
|
||||||
physician_name = serializers.CharField(source='physician.get_full_name', read_only=True)
|
staff_name = serializers.CharField(source='staff.get_full_name', read_only=True)
|
||||||
physician_license = serializers.CharField(source='physician.license_number', read_only=True)
|
staff_license = serializers.CharField(source='staff.license_number', read_only=True)
|
||||||
hospital_name = serializers.CharField(source='physician.hospital.name', read_only=True)
|
hospital_name = serializers.CharField(source='staff.hospital.name', read_only=True)
|
||||||
department_name = serializers.CharField(source='physician.department.name', read_only=True)
|
department_name = serializers.CharField(source='staff.department.name', read_only=True)
|
||||||
month_name = serializers.SerializerMethodField()
|
month_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PhysicianMonthlyRating
|
model = PhysicianMonthlyRating
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'physician', 'physician_name', 'physician_license',
|
'id', 'staff', 'staff_name', 'staff_license',
|
||||||
'hospital_name', 'department_name',
|
'hospital_name', 'department_name',
|
||||||
'year', 'month', 'month_name',
|
'year', 'month', 'month_name',
|
||||||
'average_rating', 'total_surveys',
|
'average_rating', 'total_surveys',
|
||||||
@ -48,7 +48,7 @@ class PhysicianMonthlyRatingSerializer(serializers.ModelSerializer):
|
|||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_month_name(self, obj):
|
def get_month_name(self, obj):
|
||||||
"""Get month name"""
|
"""Get month name"""
|
||||||
months = [
|
months = [
|
||||||
|
|||||||
@ -21,40 +21,40 @@ logger = logging.getLogger(__name__)
|
|||||||
def calculate_monthly_physician_ratings(self, year=None, month=None):
|
def calculate_monthly_physician_ratings(self, year=None, month=None):
|
||||||
"""
|
"""
|
||||||
Calculate physician monthly ratings from survey responses.
|
Calculate physician monthly ratings from survey responses.
|
||||||
|
|
||||||
This task aggregates all survey responses that mention physicians
|
This task aggregates all survey responses that mention physicians
|
||||||
for a given month and creates/updates PhysicianMonthlyRating records.
|
for a given month and creates/updates PhysicianMonthlyRating records.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
year: Year to calculate (default: current year)
|
year: Year to calculate (default: current year)
|
||||||
month: Month to calculate (default: current month)
|
month: Month to calculate (default: current month)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with number of ratings calculated
|
dict: Result with number of ratings calculated
|
||||||
"""
|
"""
|
||||||
from apps.organizations.models import Physician
|
from apps.organizations.models import Staff
|
||||||
from apps.physicians.models import PhysicianMonthlyRating
|
from apps.physicians.models import PhysicianMonthlyRating
|
||||||
from apps.surveys.models import SurveyInstance, SurveyResponse
|
from apps.surveys.models import SurveyInstance, SurveyResponse
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Default to current month if not specified
|
# Default to current month if not specified
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
year = year or now.year
|
year = year or now.year
|
||||||
month = month or now.month
|
month = month or now.month
|
||||||
|
|
||||||
logger.info(f"Calculating physician ratings for {year}-{month:02d}")
|
logger.info(f"Calculating physician ratings for {year}-{month:02d}")
|
||||||
|
|
||||||
# Get all active physicians
|
# Get all active physicians
|
||||||
physicians = Physician.objects.filter(status='active')
|
physicians = Staff.objects.filter(status='active')
|
||||||
|
|
||||||
ratings_created = 0
|
ratings_created = 0
|
||||||
ratings_updated = 0
|
ratings_updated = 0
|
||||||
|
|
||||||
for physician in physicians:
|
for physician in physicians:
|
||||||
# Find all completed surveys mentioning this physician
|
# Find all completed surveys mentioning this physician
|
||||||
# This assumes surveys have a physician field or question
|
# This assumes surveys have a physician field or question
|
||||||
# Adjust based on your actual survey structure
|
# Adjust based on your actual survey structure
|
||||||
|
|
||||||
# Option 1: If surveys have a direct physician field
|
# Option 1: If surveys have a direct physician field
|
||||||
surveys = SurveyInstance.objects.filter(
|
surveys = SurveyInstance.objects.filter(
|
||||||
status='completed',
|
status='completed',
|
||||||
@ -62,7 +62,7 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
|||||||
completed_at__month=month,
|
completed_at__month=month,
|
||||||
metadata__physician_id=str(physician.id)
|
metadata__physician_id=str(physician.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Option 2: If physician is mentioned in survey responses
|
# Option 2: If physician is mentioned in survey responses
|
||||||
# You may need to adjust this based on your question structure
|
# You may need to adjust this based on your question structure
|
||||||
physician_responses = SurveyResponse.objects.filter(
|
physician_responses = SurveyResponse.objects.filter(
|
||||||
@ -72,43 +72,43 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
|||||||
question__text__icontains='physician', # Adjust based on your questions
|
question__text__icontains='physician', # Adjust based on your questions
|
||||||
text_value__icontains=physician.get_full_name()
|
text_value__icontains=physician.get_full_name()
|
||||||
).values_list('survey_instance_id', flat=True).distinct()
|
).values_list('survey_instance_id', flat=True).distinct()
|
||||||
|
|
||||||
# Combine both approaches
|
# Combine both approaches
|
||||||
survey_ids = set(surveys.values_list('id', flat=True)) | set(physician_responses)
|
survey_ids = set(surveys.values_list('id', flat=True)) | set(physician_responses)
|
||||||
|
|
||||||
if not survey_ids:
|
if not survey_ids:
|
||||||
logger.debug(f"No surveys found for physician {physician.get_full_name()}")
|
logger.debug(f"No surveys found for physician {physician.get_full_name()}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get all surveys for this physician
|
# Get all surveys for this physician
|
||||||
physician_surveys = SurveyInstance.objects.filter(id__in=survey_ids)
|
physician_surveys = SurveyInstance.objects.filter(id__in=survey_ids)
|
||||||
|
|
||||||
# Calculate statistics
|
# Calculate statistics
|
||||||
total_surveys = physician_surveys.count()
|
total_surveys = physician_surveys.count()
|
||||||
|
|
||||||
# Calculate average rating
|
# Calculate average rating
|
||||||
avg_score = physician_surveys.aggregate(
|
avg_score = physician_surveys.aggregate(
|
||||||
avg=Avg('total_score')
|
avg=Avg('total_score')
|
||||||
)['avg']
|
)['avg']
|
||||||
|
|
||||||
if avg_score is None:
|
if avg_score is None:
|
||||||
logger.debug(f"No scores found for physician {physician.get_full_name()}")
|
logger.debug(f"No scores found for physician {physician.get_full_name()}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Count sentiment
|
# Count sentiment
|
||||||
positive_count = physician_surveys.filter(
|
positive_count = physician_surveys.filter(
|
||||||
total_score__gte=4.0
|
total_score__gte=4.0
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
neutral_count = physician_surveys.filter(
|
neutral_count = physician_surveys.filter(
|
||||||
total_score__gte=3.0,
|
total_score__gte=3.0,
|
||||||
total_score__lt=4.0
|
total_score__lt=4.0
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
negative_count = physician_surveys.filter(
|
negative_count = physician_surveys.filter(
|
||||||
total_score__lt=3.0
|
total_score__lt=3.0
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# Get MD consult specific rating if available
|
# Get MD consult specific rating if available
|
||||||
md_consult_surveys = physician_surveys.filter(
|
md_consult_surveys = physician_surveys.filter(
|
||||||
survey_template__survey_type='md_consult'
|
survey_template__survey_type='md_consult'
|
||||||
@ -116,10 +116,10 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
|||||||
md_consult_rating = md_consult_surveys.aggregate(
|
md_consult_rating = md_consult_surveys.aggregate(
|
||||||
avg=Avg('total_score')
|
avg=Avg('total_score')
|
||||||
)['avg']
|
)['avg']
|
||||||
|
|
||||||
# Create or update rating
|
# Create or update rating
|
||||||
rating, created = PhysicianMonthlyRating.objects.update_or_create(
|
rating, created = PhysicianMonthlyRating.objects.update_or_create(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=year,
|
year=year,
|
||||||
month=month,
|
month=month,
|
||||||
defaults={
|
defaults={
|
||||||
@ -135,25 +135,25 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
ratings_created += 1
|
ratings_created += 1
|
||||||
else:
|
else:
|
||||||
ratings_updated += 1
|
ratings_updated += 1
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{'Created' if created else 'Updated'} rating for {physician.get_full_name()}: "
|
f"{'Created' if created else 'Updated'} rating for {physician.get_full_name()}: "
|
||||||
f"{avg_score:.2f} ({total_surveys} surveys)"
|
f"{avg_score:.2f} ({total_surveys} surveys)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update rankings
|
# Update rankings
|
||||||
update_physician_rankings.delay(year, month)
|
update_physician_rankings.delay(year, month)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Completed physician ratings calculation for {year}-{month:02d}: "
|
f"Completed physician ratings calculation for {year}-{month:02d}: "
|
||||||
f"{ratings_created} created, {ratings_updated} updated"
|
f"{ratings_created} created, {ratings_updated} updated"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'year': year,
|
'year': year,
|
||||||
@ -161,11 +161,11 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
|||||||
'ratings_created': ratings_created,
|
'ratings_created': ratings_created,
|
||||||
'ratings_updated': ratings_updated
|
'ratings_updated': ratings_updated
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error calculating physician ratings: {str(e)}"
|
error_msg = f"Error calculating physician ratings: {str(e)}"
|
||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
|
|
||||||
# Retry the task
|
# Retry the task
|
||||||
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
|
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
|
||||||
|
|
||||||
@ -174,67 +174,67 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
|||||||
def update_physician_rankings(year, month):
|
def update_physician_rankings(year, month):
|
||||||
"""
|
"""
|
||||||
Update hospital and department rankings for physicians.
|
Update hospital and department rankings for physicians.
|
||||||
|
|
||||||
This calculates the rank of each physician within their hospital
|
This calculates the rank of each physician within their hospital
|
||||||
and department for the specified month.
|
and department for the specified month.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
year: Year
|
year: Year
|
||||||
month: Month
|
month: Month
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with number of rankings updated
|
dict: Result with number of rankings updated
|
||||||
"""
|
"""
|
||||||
from apps.organizations.models import Hospital, Department
|
from apps.organizations.models import Hospital, Department
|
||||||
from apps.physicians.models import PhysicianMonthlyRating
|
from apps.physicians.models import PhysicianMonthlyRating
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Updating physician rankings for {year}-{month:02d}")
|
logger.info(f"Updating physician rankings for {year}-{month:02d}")
|
||||||
|
|
||||||
rankings_updated = 0
|
rankings_updated = 0
|
||||||
|
|
||||||
# Update hospital rankings
|
# Update hospital rankings
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
|
|
||||||
for hospital in hospitals:
|
for hospital in hospitals:
|
||||||
# Get all ratings for this hospital
|
# Get all ratings for this hospital
|
||||||
ratings = PhysicianMonthlyRating.objects.filter(
|
ratings = PhysicianMonthlyRating.objects.filter(
|
||||||
physician__hospital=hospital,
|
staff__hospital=hospital,
|
||||||
year=year,
|
year=year,
|
||||||
month=month
|
month=month
|
||||||
).order_by('-average_rating')
|
).order_by('-average_rating')
|
||||||
|
|
||||||
# Assign ranks
|
# Assign ranks
|
||||||
for rank, rating in enumerate(ratings, start=1):
|
for rank, rating in enumerate(ratings, start=1):
|
||||||
rating.hospital_rank = rank
|
rating.hospital_rank = rank
|
||||||
rating.save(update_fields=['hospital_rank'])
|
rating.save(update_fields=['hospital_rank'])
|
||||||
rankings_updated += 1
|
rankings_updated += 1
|
||||||
|
|
||||||
# Update department rankings
|
# Update department rankings
|
||||||
departments = Department.objects.filter(status='active')
|
departments = Department.objects.filter(status='active')
|
||||||
|
|
||||||
for department in departments:
|
for department in departments:
|
||||||
# Get all ratings for this department
|
# Get all ratings for this department
|
||||||
ratings = PhysicianMonthlyRating.objects.filter(
|
ratings = PhysicianMonthlyRating.objects.filter(
|
||||||
physician__department=department,
|
staff__department=department,
|
||||||
year=year,
|
year=year,
|
||||||
month=month
|
month=month
|
||||||
).order_by('-average_rating')
|
).order_by('-average_rating')
|
||||||
|
|
||||||
# Assign ranks
|
# Assign ranks
|
||||||
for rank, rating in enumerate(ratings, start=1):
|
for rank, rating in enumerate(ratings, start=1):
|
||||||
rating.department_rank = rank
|
rating.department_rank = rank
|
||||||
rating.save(update_fields=['department_rank'])
|
rating.save(update_fields=['department_rank'])
|
||||||
|
|
||||||
logger.info(f"Updated {rankings_updated} physician rankings for {year}-{month:02d}")
|
logger.info(f"Updated {rankings_updated} physician rankings for {year}-{month:02d}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'year': year,
|
'year': year,
|
||||||
'month': month,
|
'month': month,
|
||||||
'rankings_updated': rankings_updated
|
'rankings_updated': rankings_updated
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error updating physician rankings: {str(e)}"
|
error_msg = f"Error updating physician rankings: {str(e)}"
|
||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
@ -245,59 +245,59 @@ def update_physician_rankings(year, month):
|
|||||||
def generate_physician_performance_report(physician_id, year, month):
|
def generate_physician_performance_report(physician_id, year, month):
|
||||||
"""
|
"""
|
||||||
Generate detailed performance report for a physician.
|
Generate detailed performance report for a physician.
|
||||||
|
|
||||||
This creates a comprehensive report including:
|
This creates a comprehensive report including:
|
||||||
- Monthly rating
|
- Monthly rating
|
||||||
- Comparison to previous months
|
- Comparison to previous months
|
||||||
- Ranking within hospital/department
|
- Ranking within hospital/department
|
||||||
- Trend analysis
|
- Trend analysis
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
physician_id: UUID of Physician
|
physician_id: UUID of Physician
|
||||||
year: Year
|
year: Year
|
||||||
month: Month
|
month: Month
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Performance report data
|
dict: Performance report data
|
||||||
"""
|
"""
|
||||||
from apps.organizations.models import Physician
|
from apps.organizations.models import Staff
|
||||||
from apps.physicians.models import PhysicianMonthlyRating
|
from apps.physicians.models import PhysicianMonthlyRating
|
||||||
|
|
||||||
try:
|
try:
|
||||||
physician = Physician.objects.get(id=physician_id)
|
physician = Staff.objects.get(id=physician_id)
|
||||||
|
|
||||||
# Get current month rating
|
# Get current month rating
|
||||||
current_rating = PhysicianMonthlyRating.objects.filter(
|
current_rating = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=year,
|
year=year,
|
||||||
month=month
|
month=month
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not current_rating:
|
if not current_rating:
|
||||||
return {
|
return {
|
||||||
'status': 'no_data',
|
'status': 'no_data',
|
||||||
'reason': f'No rating found for {year}-{month:02d}'
|
'reason': f'No rating found for {year}-{month:02d}'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get previous month
|
# Get previous month
|
||||||
prev_month = month - 1 if month > 1 else 12
|
prev_month = month - 1 if month > 1 else 12
|
||||||
prev_year = year if month > 1 else year - 1
|
prev_year = year if month > 1 else year - 1
|
||||||
|
|
||||||
previous_rating = PhysicianMonthlyRating.objects.filter(
|
previous_rating = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=prev_year,
|
year=prev_year,
|
||||||
month=prev_month
|
month=prev_month
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# Get year-to-date stats
|
# Get year-to-date stats
|
||||||
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=year
|
year=year
|
||||||
)
|
)
|
||||||
|
|
||||||
ytd_avg = ytd_ratings.aggregate(avg=Avg('average_rating'))['avg']
|
ytd_avg = ytd_ratings.aggregate(avg=Avg('average_rating'))['avg']
|
||||||
ytd_surveys = ytd_ratings.aggregate(total=Count('total_surveys'))['total']
|
ytd_surveys = ytd_ratings.aggregate(total=Count('total_surveys'))['total']
|
||||||
|
|
||||||
# Calculate trend
|
# Calculate trend
|
||||||
trend = 'stable'
|
trend = 'stable'
|
||||||
if previous_rating:
|
if previous_rating:
|
||||||
@ -306,7 +306,7 @@ def generate_physician_performance_report(physician_id, year, month):
|
|||||||
trend = 'improving'
|
trend = 'improving'
|
||||||
elif diff < -0.1:
|
elif diff < -0.1:
|
||||||
trend = 'declining'
|
trend = 'declining'
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'physician': {
|
'physician': {
|
||||||
@ -333,16 +333,16 @@ def generate_physician_performance_report(physician_id, year, month):
|
|||||||
},
|
},
|
||||||
'trend': trend
|
'trend': trend
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Generated performance report for {physician.get_full_name()}")
|
logger.info(f"Generated performance report for {physician.get_full_name()}")
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
except Physician.DoesNotExist:
|
except Staff.DoesNotExist:
|
||||||
error_msg = f"Physician {physician_id} not found"
|
error_msg = f"Physician {physician_id} not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {'status': 'error', 'reason': error_msg}
|
return {'status': 'error', 'reason': error_msg}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error generating performance report: {str(e)}"
|
error_msg = f"Error generating performance report: {str(e)}"
|
||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
@ -353,27 +353,27 @@ def generate_physician_performance_report(physician_id, year, month):
|
|||||||
def schedule_monthly_rating_calculation():
|
def schedule_monthly_rating_calculation():
|
||||||
"""
|
"""
|
||||||
Scheduled task to calculate physician ratings for the previous month.
|
Scheduled task to calculate physician ratings for the previous month.
|
||||||
|
|
||||||
This should be run on the 1st of each month to calculate ratings
|
This should be run on the 1st of each month to calculate ratings
|
||||||
for the previous month.
|
for the previous month.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result of calculation
|
dict: Result of calculation
|
||||||
"""
|
"""
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
# Calculate for previous month
|
# Calculate for previous month
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
prev_month = now - relativedelta(months=1)
|
prev_month = now - relativedelta(months=1)
|
||||||
|
|
||||||
year = prev_month.year
|
year = prev_month.year
|
||||||
month = prev_month.month
|
month = prev_month.month
|
||||||
|
|
||||||
logger.info(f"Scheduled calculation of physician ratings for {year}-{month:02d}")
|
logger.info(f"Scheduled calculation of physician ratings for {year}-{month:02d}")
|
||||||
|
|
||||||
# Trigger calculation
|
# Trigger calculation
|
||||||
result = calculate_monthly_physician_ratings.delay(year, month)
|
result = calculate_monthly_physician_ratings.delay(year, month)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'scheduled',
|
'status': 'scheduled',
|
||||||
'year': year,
|
'year': year,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from django.db.models import Avg, Count, Q
|
|||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.organizations.models import Department, Hospital, Physician
|
from apps.organizations.models import Department, Hospital, Staff
|
||||||
|
|
||||||
from .models import PhysicianMonthlyRating
|
from .models import PhysicianMonthlyRating
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ from .models import PhysicianMonthlyRating
|
|||||||
def physician_list(request):
|
def physician_list(request):
|
||||||
"""
|
"""
|
||||||
Physicians list view with filters.
|
Physicians list view with filters.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Server-side pagination
|
- Server-side pagination
|
||||||
- Filters (hospital, department, specialization, status)
|
- Filters (hospital, department, specialization, status)
|
||||||
@ -24,8 +24,8 @@ def physician_list(request):
|
|||||||
- Current month rating display
|
- Current month rating display
|
||||||
"""
|
"""
|
||||||
# Base queryset with optimizations
|
# Base queryset with optimizations
|
||||||
queryset = Physician.objects.select_related('hospital', 'department')
|
queryset = Staff.objects.select_related('hospital', 'department')
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
@ -34,24 +34,24 @@ def physician_list(request):
|
|||||||
queryset = queryset.filter(hospital=user.hospital)
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
department_filter = request.GET.get('department')
|
department_filter = request.GET.get('department')
|
||||||
if department_filter:
|
if department_filter:
|
||||||
queryset = queryset.filter(department_id=department_filter)
|
queryset = queryset.filter(department_id=department_filter)
|
||||||
|
|
||||||
specialization_filter = request.GET.get('specialization')
|
specialization_filter = request.GET.get('specialization')
|
||||||
if specialization_filter:
|
if specialization_filter:
|
||||||
queryset = queryset.filter(specialization__icontains=specialization_filter)
|
queryset = queryset.filter(specialization__icontains=specialization_filter)
|
||||||
|
|
||||||
status_filter = request.GET.get('status', 'active')
|
status_filter = request.GET.get('status', 'active')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
@ -61,51 +61,51 @@ def physician_list(request):
|
|||||||
Q(license_number__icontains=search_query) |
|
Q(license_number__icontains=search_query) |
|
||||||
Q(specialization__icontains=search_query)
|
Q(specialization__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
order_by = request.GET.get('order_by', 'last_name')
|
order_by = request.GET.get('order_by', 'last_name')
|
||||||
queryset = queryset.order_by(order_by)
|
queryset = queryset.order_by(order_by)
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get current month ratings for displayed physicians
|
# Get current month ratings for displayed physicians
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
physician_ids = [p.id for p in page_obj.object_list]
|
physician_ids = [p.id for p in page_obj.object_list]
|
||||||
current_ratings = PhysicianMonthlyRating.objects.filter(
|
current_ratings = PhysicianMonthlyRating.objects.filter(
|
||||||
physician_id__in=physician_ids,
|
staff_id__in=physician_ids,
|
||||||
year=now.year,
|
year=now.year,
|
||||||
month=now.month
|
month=now.month
|
||||||
).select_related('physician')
|
).select_related('staff')
|
||||||
|
|
||||||
# Create rating lookup
|
# Create rating lookup
|
||||||
ratings_dict = {r.physician_id: r for r in current_ratings}
|
ratings_dict = {r.staff_id: r for r in current_ratings}
|
||||||
|
|
||||||
# Attach ratings to physicians
|
# Attach ratings to physicians
|
||||||
for physician in page_obj.object_list:
|
for physician in page_obj.object_list:
|
||||||
physician.current_rating = ratings_dict.get(physician.id)
|
physician.current_rating = ratings_dict.get(physician.id)
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
departments = Department.objects.filter(status='active')
|
departments = Department.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
departments = departments.filter(hospital=user.hospital)
|
departments = departments.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Get unique specializations
|
# Get unique specializations
|
||||||
specializations = Physician.objects.values_list('specialization', flat=True).distinct().order_by('specialization')
|
specializations = Staff.objects.values_list('specialization', flat=True).distinct().order_by('specialization')
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
stats = {
|
stats = {
|
||||||
'total': queryset.count(),
|
'total': queryset.count(),
|
||||||
'active': queryset.filter(status='active').count(),
|
'active': queryset.filter(status='active').count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'physicians': page_obj.object_list,
|
'physicians': page_obj.object_list,
|
||||||
@ -117,7 +117,7 @@ def physician_list(request):
|
|||||||
'current_year': now.year,
|
'current_year': now.year,
|
||||||
'current_month': now.month,
|
'current_month': now.month,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'physicians/physician_list.html', context)
|
return render(request, 'physicians/physician_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -125,7 +125,7 @@ def physician_list(request):
|
|||||||
def physician_detail(request, pk):
|
def physician_detail(request, pk):
|
||||||
"""
|
"""
|
||||||
Physician detail view with performance metrics.
|
Physician detail view with performance metrics.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Full physician details
|
- Full physician details
|
||||||
- Current month rating
|
- Current month rating
|
||||||
@ -134,58 +134,58 @@ def physician_detail(request, pk):
|
|||||||
- Performance trends
|
- Performance trends
|
||||||
"""
|
"""
|
||||||
physician = get_object_or_404(
|
physician = get_object_or_404(
|
||||||
Physician.objects.select_related('hospital', 'department'),
|
Staff.objects.select_related('hospital', 'department'),
|
||||||
pk=pk
|
pk=pk
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
if physician.hospital != user.hospital:
|
if physician.hospital != user.hospital:
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
raise Http404("Physician not found")
|
raise Http404("Physician not found")
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
current_year = now.year
|
current_year = now.year
|
||||||
current_month = now.month
|
current_month = now.month
|
||||||
|
|
||||||
# Get current month rating
|
# Get current month rating
|
||||||
current_month_rating = PhysicianMonthlyRating.objects.filter(
|
current_month_rating = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month
|
month=current_month
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# Get previous month rating
|
# Get previous month rating
|
||||||
prev_month = current_month - 1 if current_month > 1 else 12
|
prev_month = current_month - 1 if current_month > 1 else 12
|
||||||
prev_year = current_year if current_month > 1 else current_year - 1
|
prev_year = current_year if current_month > 1 else current_year - 1
|
||||||
previous_month_rating = PhysicianMonthlyRating.objects.filter(
|
previous_month_rating = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=prev_year,
|
year=prev_year,
|
||||||
month=prev_month
|
month=prev_month
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# Get year-to-date stats
|
# Get year-to-date stats
|
||||||
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=current_year
|
year=current_year
|
||||||
)
|
)
|
||||||
|
|
||||||
ytd_stats = ytd_ratings.aggregate(
|
ytd_stats = ytd_ratings.aggregate(
|
||||||
avg_rating=Avg('average_rating'),
|
avg_rating=Avg('average_rating'),
|
||||||
total_surveys=Count('id')
|
total_surveys=Count('id')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get last 12 months ratings
|
# Get last 12 months ratings
|
||||||
ratings_history = PhysicianMonthlyRating.objects.filter(
|
ratings_history = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician
|
staff=physician
|
||||||
).order_by('-year', '-month')[:12]
|
).order_by('-year', '-month')[:12]
|
||||||
|
|
||||||
# Get best and worst months from all ratings (not just last 12 months)
|
# Get best and worst months from all ratings (not just last 12 months)
|
||||||
all_ratings = PhysicianMonthlyRating.objects.filter(physician=physician)
|
all_ratings = PhysicianMonthlyRating.objects.filter(staff=physician)
|
||||||
best_month = all_ratings.order_by('-average_rating').first()
|
best_month = all_ratings.order_by('-average_rating').first()
|
||||||
worst_month = all_ratings.order_by('average_rating').first()
|
worst_month = all_ratings.order_by('average_rating').first()
|
||||||
|
|
||||||
# Determine trend
|
# Determine trend
|
||||||
trend = 'stable'
|
trend = 'stable'
|
||||||
trend_percentage = 0
|
trend_percentage = 0
|
||||||
@ -193,12 +193,12 @@ def physician_detail(request, pk):
|
|||||||
diff = float(current_month_rating.average_rating - previous_month_rating.average_rating)
|
diff = float(current_month_rating.average_rating - previous_month_rating.average_rating)
|
||||||
if previous_month_rating.average_rating > 0:
|
if previous_month_rating.average_rating > 0:
|
||||||
trend_percentage = (diff / float(previous_month_rating.average_rating)) * 100
|
trend_percentage = (diff / float(previous_month_rating.average_rating)) * 100
|
||||||
|
|
||||||
if diff > 0.1:
|
if diff > 0.1:
|
||||||
trend = 'improving'
|
trend = 'improving'
|
||||||
elif diff < -0.1:
|
elif diff < -0.1:
|
||||||
trend = 'declining'
|
trend = 'declining'
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'physician': physician,
|
'physician': physician,
|
||||||
'current_month_rating': current_month_rating,
|
'current_month_rating': current_month_rating,
|
||||||
@ -213,7 +213,7 @@ def physician_detail(request, pk):
|
|||||||
'current_year': current_year,
|
'current_year': current_year,
|
||||||
'current_month': current_month,
|
'current_month': current_month,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'physicians/physician_detail.html', context)
|
return render(request, 'physicians/physician_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -221,7 +221,7 @@ def physician_detail(request, pk):
|
|||||||
def leaderboard(request):
|
def leaderboard(request):
|
||||||
"""
|
"""
|
||||||
Physician leaderboard view.
|
Physician leaderboard view.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Top-rated physicians for selected period
|
- Top-rated physicians for selected period
|
||||||
- Filters (hospital, department, month/year)
|
- Filters (hospital, department, month/year)
|
||||||
@ -235,42 +235,42 @@ def leaderboard(request):
|
|||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
department_filter = request.GET.get('department')
|
department_filter = request.GET.get('department')
|
||||||
limit = int(request.GET.get('limit', 20))
|
limit = int(request.GET.get('limit', 20))
|
||||||
|
|
||||||
# Build queryset
|
# Build queryset
|
||||||
queryset = PhysicianMonthlyRating.objects.filter(
|
queryset = PhysicianMonthlyRating.objects.filter(
|
||||||
year=year,
|
year=year,
|
||||||
month=month
|
month=month
|
||||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(physician__hospital=user.hospital)
|
queryset = queryset.filter(staff__hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(physician__hospital_id=hospital_filter)
|
queryset = queryset.filter(staff__hospital_id=hospital_filter)
|
||||||
|
|
||||||
if department_filter:
|
if department_filter:
|
||||||
queryset = queryset.filter(physician__department_id=department_filter)
|
queryset = queryset.filter(staff__department_id=department_filter)
|
||||||
|
|
||||||
# Order by rating
|
# Order by rating
|
||||||
queryset = queryset.order_by('-average_rating')[:limit]
|
queryset = queryset.order_by('-average_rating')[:limit]
|
||||||
|
|
||||||
# Get previous month for trend
|
# Get previous month for trend
|
||||||
prev_month = month - 1 if month > 1 else 12
|
prev_month = month - 1 if month > 1 else 12
|
||||||
prev_year = year if month > 1 else year - 1
|
prev_year = year if month > 1 else year - 1
|
||||||
|
|
||||||
# Build leaderboard with trends
|
# Build leaderboard with trends
|
||||||
leaderboard = []
|
leaderboard = []
|
||||||
for rank, rating in enumerate(queryset, start=1):
|
for rank, rating in enumerate(queryset, start=1):
|
||||||
# Get previous month rating for trend
|
# Get previous month rating for trend
|
||||||
prev_rating = PhysicianMonthlyRating.objects.filter(
|
prev_rating = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=rating.physician,
|
staff=rating.staff,
|
||||||
year=prev_year,
|
year=prev_year,
|
||||||
month=prev_month
|
month=prev_month
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
trend = 'stable'
|
trend = 'stable'
|
||||||
trend_value = 0
|
trend_value = 0
|
||||||
if prev_rating:
|
if prev_rating:
|
||||||
@ -280,42 +280,42 @@ def leaderboard(request):
|
|||||||
trend = 'up'
|
trend = 'up'
|
||||||
elif diff < -0.1:
|
elif diff < -0.1:
|
||||||
trend = 'down'
|
trend = 'down'
|
||||||
|
|
||||||
leaderboard.append({
|
leaderboard.append({
|
||||||
'rank': rank,
|
'rank': rank,
|
||||||
'rating': rating,
|
'rating': rating,
|
||||||
'physician': rating.physician,
|
'physician': rating.staff,
|
||||||
'trend': trend,
|
'trend': trend,
|
||||||
'trend_value': trend_value,
|
'trend_value': trend_value,
|
||||||
'prev_rating': prev_rating
|
'prev_rating': prev_rating
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
departments = Department.objects.filter(status='active')
|
departments = Department.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
departments = departments.filter(hospital=user.hospital)
|
departments = departments.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Calculate statistics
|
# Calculate statistics
|
||||||
all_ratings = PhysicianMonthlyRating.objects.filter(year=year, month=month)
|
all_ratings = PhysicianMonthlyRating.objects.filter(year=year, month=month)
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
all_ratings = all_ratings.filter(physician__hospital=user.hospital)
|
all_ratings = all_ratings.filter(staff__hospital=user.hospital)
|
||||||
|
|
||||||
stats = all_ratings.aggregate(
|
stats = all_ratings.aggregate(
|
||||||
total_physicians=Count('id'),
|
total_physicians=Count('id'),
|
||||||
average_rating=Avg('average_rating'),
|
average_rating=Avg('average_rating'),
|
||||||
total_surveys=Count('total_surveys')
|
total_surveys=Count('total_surveys')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Distribution
|
# Distribution
|
||||||
excellent = all_ratings.filter(average_rating__gte=4.5).count()
|
excellent = all_ratings.filter(average_rating__gte=4.5).count()
|
||||||
good = all_ratings.filter(average_rating__gte=3.5, average_rating__lt=4.5).count()
|
good = all_ratings.filter(average_rating__gte=3.5, average_rating__lt=4.5).count()
|
||||||
average = all_ratings.filter(average_rating__gte=2.5, average_rating__lt=3.5).count()
|
average = all_ratings.filter(average_rating__gte=2.5, average_rating__lt=3.5).count()
|
||||||
poor = all_ratings.filter(average_rating__lt=2.5).count()
|
poor = all_ratings.filter(average_rating__lt=2.5).count()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'leaderboard': leaderboard,
|
'leaderboard': leaderboard,
|
||||||
'year': year,
|
'year': year,
|
||||||
@ -331,7 +331,7 @@ def leaderboard(request):
|
|||||||
'poor': poor
|
'poor': poor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'physicians/leaderboard.html', context)
|
return render(request, 'physicians/leaderboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -339,7 +339,7 @@ def leaderboard(request):
|
|||||||
def ratings_list(request):
|
def ratings_list(request):
|
||||||
"""
|
"""
|
||||||
Monthly ratings list view with filters.
|
Monthly ratings list view with filters.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- All monthly ratings
|
- All monthly ratings
|
||||||
- Filters (physician, hospital, department, year, month)
|
- Filters (physician, hospital, department, year, month)
|
||||||
@ -348,66 +348,66 @@ def ratings_list(request):
|
|||||||
"""
|
"""
|
||||||
# Base queryset
|
# Base queryset
|
||||||
queryset = PhysicianMonthlyRating.objects.select_related(
|
queryset = PhysicianMonthlyRating.objects.select_related(
|
||||||
'physician', 'physician__hospital', 'physician__department'
|
'staff', 'staff__hospital', 'staff__department'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(physician__hospital=user.hospital)
|
queryset = queryset.filter(staff__hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
physician_filter = request.GET.get('physician')
|
physician_filter = request.GET.get('physician')
|
||||||
if physician_filter:
|
if physician_filter:
|
||||||
queryset = queryset.filter(physician_id=physician_filter)
|
queryset = queryset.filter(staff_id=physician_filter)
|
||||||
|
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(physician__hospital_id=hospital_filter)
|
queryset = queryset.filter(staff__hospital_id=hospital_filter)
|
||||||
|
|
||||||
department_filter = request.GET.get('department')
|
department_filter = request.GET.get('department')
|
||||||
if department_filter:
|
if department_filter:
|
||||||
queryset = queryset.filter(physician__department_id=department_filter)
|
queryset = queryset.filter(staff__department_id=department_filter)
|
||||||
|
|
||||||
year_filter = request.GET.get('year')
|
year_filter = request.GET.get('year')
|
||||||
if year_filter:
|
if year_filter:
|
||||||
queryset = queryset.filter(year=int(year_filter))
|
queryset = queryset.filter(year=int(year_filter))
|
||||||
|
|
||||||
month_filter = request.GET.get('month')
|
month_filter = request.GET.get('month')
|
||||||
if month_filter:
|
if month_filter:
|
||||||
queryset = queryset.filter(month=int(month_filter))
|
queryset = queryset.filter(month=int(month_filter))
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get('search')
|
search_query = request.GET.get('search')
|
||||||
if search_query:
|
if search_query:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(physician__first_name__icontains=search_query) |
|
Q(staff__first_name__icontains=search_query) |
|
||||||
Q(physician__last_name__icontains=search_query) |
|
Q(staff__last_name__icontains=search_query) |
|
||||||
Q(physician__license_number__icontains=search_query)
|
Q(staff__license_number__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ordering
|
# Ordering
|
||||||
order_by = request.GET.get('order_by', '-year,-month,-average_rating')
|
order_by = request.GET.get('order_by', '-year,-month,-average_rating')
|
||||||
queryset = queryset.order_by(*order_by.split(','))
|
queryset = queryset.order_by(*order_by.split(','))
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_size = int(request.GET.get('page_size', 25))
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get('page', 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
departments = Department.objects.filter(status='active')
|
departments = Department.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
departments = departments.filter(hospital=user.hospital)
|
departments = departments.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Get available years
|
# Get available years
|
||||||
years = PhysicianMonthlyRating.objects.values_list('year', flat=True).distinct().order_by('-year')
|
years = PhysicianMonthlyRating.objects.values_list('year', flat=True).distinct().order_by('-year')
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
'page_obj': page_obj,
|
||||||
'ratings': page_obj.object_list,
|
'ratings': page_obj.object_list,
|
||||||
@ -416,7 +416,7 @@ def ratings_list(request):
|
|||||||
'years': years,
|
'years': years,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'physicians/ratings_list.html', context)
|
return render(request, 'physicians/ratings_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -424,7 +424,7 @@ def ratings_list(request):
|
|||||||
def specialization_overview(request):
|
def specialization_overview(request):
|
||||||
"""
|
"""
|
||||||
Specialization overview - aggregated ratings by specialization.
|
Specialization overview - aggregated ratings by specialization.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Average rating per specialization
|
- Average rating per specialization
|
||||||
- Total physicians per specialization
|
- Total physicians per specialization
|
||||||
@ -436,28 +436,28 @@ def specialization_overview(request):
|
|||||||
year = int(request.GET.get('year', now.year))
|
year = int(request.GET.get('year', now.year))
|
||||||
month = int(request.GET.get('month', now.month))
|
month = int(request.GET.get('month', now.month))
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
|
|
||||||
# Base queryset
|
# Base queryset
|
||||||
queryset = PhysicianMonthlyRating.objects.filter(
|
queryset = PhysicianMonthlyRating.objects.filter(
|
||||||
year=year,
|
year=year,
|
||||||
month=month
|
month=month
|
||||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(physician__hospital=user.hospital)
|
queryset = queryset.filter(staff__hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(physician__hospital_id=hospital_filter)
|
queryset = queryset.filter(staff__hospital_id=hospital_filter)
|
||||||
|
|
||||||
# Aggregate by specialization
|
# Aggregate by specialization
|
||||||
from django.db.models import Avg, Count, Sum
|
from django.db.models import Avg, Count, Sum
|
||||||
|
|
||||||
specialization_data = {}
|
specialization_data = {}
|
||||||
for rating in queryset:
|
for rating in queryset:
|
||||||
spec = rating.physician.specialization
|
spec = rating.staff.specialization
|
||||||
if spec not in specialization_data:
|
if spec not in specialization_data:
|
||||||
specialization_data[spec] = {
|
specialization_data[spec] = {
|
||||||
'specialization': spec,
|
'specialization': spec,
|
||||||
@ -469,7 +469,7 @@ def specialization_overview(request):
|
|||||||
'total_negative': 0,
|
'total_negative': 0,
|
||||||
'ratings_sum': 0,
|
'ratings_sum': 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
specialization_data[spec]['physicians'].append(rating)
|
specialization_data[spec]['physicians'].append(rating)
|
||||||
specialization_data[spec]['total_physicians'] += 1
|
specialization_data[spec]['total_physicians'] += 1
|
||||||
specialization_data[spec]['total_surveys'] += rating.total_surveys
|
specialization_data[spec]['total_surveys'] += rating.total_surveys
|
||||||
@ -477,7 +477,7 @@ def specialization_overview(request):
|
|||||||
specialization_data[spec]['total_neutral'] += rating.neutral_count
|
specialization_data[spec]['total_neutral'] += rating.neutral_count
|
||||||
specialization_data[spec]['total_negative'] += rating.negative_count
|
specialization_data[spec]['total_negative'] += rating.negative_count
|
||||||
specialization_data[spec]['ratings_sum'] += float(rating.average_rating)
|
specialization_data[spec]['ratings_sum'] += float(rating.average_rating)
|
||||||
|
|
||||||
# Calculate averages
|
# Calculate averages
|
||||||
specializations = []
|
specializations = []
|
||||||
for spec, data in specialization_data.items():
|
for spec, data in specialization_data.items():
|
||||||
@ -492,15 +492,15 @@ def specialization_overview(request):
|
|||||||
'negative_count': data['total_negative'],
|
'negative_count': data['total_negative'],
|
||||||
'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True)
|
'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by average rating
|
# Sort by average rating
|
||||||
specializations.sort(key=lambda x: x['average_rating'], reverse=True)
|
specializations.sort(key=lambda x: x['average_rating'], reverse=True)
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'specializations': specializations,
|
'specializations': specializations,
|
||||||
'year': year,
|
'year': year,
|
||||||
@ -508,7 +508,7 @@ def specialization_overview(request):
|
|||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'physicians/specialization_overview.html', context)
|
return render(request, 'physicians/specialization_overview.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -516,7 +516,7 @@ def specialization_overview(request):
|
|||||||
def department_overview(request):
|
def department_overview(request):
|
||||||
"""
|
"""
|
||||||
Department overview - aggregated ratings by department.
|
Department overview - aggregated ratings by department.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Average rating per department
|
- Average rating per department
|
||||||
- Total physicians per department
|
- Total physicians per department
|
||||||
@ -528,31 +528,31 @@ def department_overview(request):
|
|||||||
year = int(request.GET.get('year', now.year))
|
year = int(request.GET.get('year', now.year))
|
||||||
month = int(request.GET.get('month', now.month))
|
month = int(request.GET.get('month', now.month))
|
||||||
hospital_filter = request.GET.get('hospital')
|
hospital_filter = request.GET.get('hospital')
|
||||||
|
|
||||||
# Base queryset
|
# Base queryset
|
||||||
queryset = PhysicianMonthlyRating.objects.filter(
|
queryset = PhysicianMonthlyRating.objects.filter(
|
||||||
year=year,
|
year=year,
|
||||||
month=month
|
month=month
|
||||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(physician__hospital=user.hospital)
|
queryset = queryset.filter(staff__hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
queryset = queryset.filter(physician__hospital_id=hospital_filter)
|
queryset = queryset.filter(staff__hospital_id=hospital_filter)
|
||||||
|
|
||||||
# Aggregate by department
|
# Aggregate by department
|
||||||
from django.db.models import Avg, Count, Sum
|
from django.db.models import Avg, Count, Sum
|
||||||
|
|
||||||
department_data = {}
|
department_data = {}
|
||||||
for rating in queryset:
|
for rating in queryset:
|
||||||
dept = rating.physician.department
|
dept = rating.staff.department
|
||||||
if not dept:
|
if not dept:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dept_key = str(dept.id)
|
dept_key = str(dept.id)
|
||||||
if dept_key not in department_data:
|
if dept_key not in department_data:
|
||||||
department_data[dept_key] = {
|
department_data[dept_key] = {
|
||||||
@ -565,7 +565,7 @@ def department_overview(request):
|
|||||||
'total_negative': 0,
|
'total_negative': 0,
|
||||||
'ratings_sum': 0,
|
'ratings_sum': 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
department_data[dept_key]['physicians'].append(rating)
|
department_data[dept_key]['physicians'].append(rating)
|
||||||
department_data[dept_key]['total_physicians'] += 1
|
department_data[dept_key]['total_physicians'] += 1
|
||||||
department_data[dept_key]['total_surveys'] += rating.total_surveys
|
department_data[dept_key]['total_surveys'] += rating.total_surveys
|
||||||
@ -573,7 +573,7 @@ def department_overview(request):
|
|||||||
department_data[dept_key]['total_neutral'] += rating.neutral_count
|
department_data[dept_key]['total_neutral'] += rating.neutral_count
|
||||||
department_data[dept_key]['total_negative'] += rating.negative_count
|
department_data[dept_key]['total_negative'] += rating.negative_count
|
||||||
department_data[dept_key]['ratings_sum'] += float(rating.average_rating)
|
department_data[dept_key]['ratings_sum'] += float(rating.average_rating)
|
||||||
|
|
||||||
# Calculate averages
|
# Calculate averages
|
||||||
departments = []
|
departments = []
|
||||||
for dept_key, data in department_data.items():
|
for dept_key, data in department_data.items():
|
||||||
@ -588,15 +588,15 @@ def department_overview(request):
|
|||||||
'negative_count': data['total_negative'],
|
'negative_count': data['total_negative'],
|
||||||
'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True)
|
'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by average rating
|
# Sort by average rating
|
||||||
departments.sort(key=lambda x: x['average_rating'], reverse=True)
|
departments.sort(key=lambda x: x['average_rating'], reverse=True)
|
||||||
|
|
||||||
# Get filter options
|
# Get filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'departments': departments,
|
'departments': departments,
|
||||||
'year': year,
|
'year': year,
|
||||||
@ -604,5 +604,5 @@ def department_overview(request):
|
|||||||
'hospitals': hospitals,
|
'hospitals': hospitals,
|
||||||
'filters': request.GET,
|
'filters': request.GET,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'physicians/department_overview.html', context)
|
return render(request, 'physicians/department_overview.html', context)
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from rest_framework.permissions import IsAuthenticated
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from apps.accounts.permissions import IsPXAdminOrHospitalAdmin
|
from apps.accounts.permissions import IsPXAdminOrHospitalAdmin
|
||||||
from apps.organizations.models import Physician
|
from apps.organizations.models import Staff
|
||||||
|
|
||||||
from .models import PhysicianMonthlyRating
|
from .models import PhysicianMonthlyRating
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
@ -22,41 +22,41 @@ from .serializers import (
|
|||||||
class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Physicians.
|
ViewSet for Physicians.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- All authenticated users can view physicians
|
- All authenticated users can view physicians
|
||||||
- Filtered by hospital based on user role
|
- Filtered by hospital based on user role
|
||||||
"""
|
"""
|
||||||
queryset = Physician.objects.all()
|
queryset = Staff.objects.all()
|
||||||
serializer_class = PhysicianSerializer
|
serializer_class = PhysicianSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['hospital', 'department', 'specialization', 'status']
|
filterset_fields = ['hospital', 'department', 'specialization', 'status']
|
||||||
search_fields = ['first_name', 'last_name', 'license_number', 'specialization']
|
search_fields = ['first_name', 'last_name', 'license_number', 'specialization']
|
||||||
ordering_fields = ['last_name', 'first_name', 'specialization', 'created_at']
|
ordering_fields = ['last_name', 'first_name', 'specialization', 'created_at']
|
||||||
ordering = ['last_name', 'first_name']
|
ordering = ['last_name', 'first_name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter physicians based on user role"""
|
"""Filter physicians based on user role"""
|
||||||
queryset = super().get_queryset().select_related('hospital', 'department')
|
queryset = super().get_queryset().select_related('hospital', 'department')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all physicians
|
# PX Admins see all physicians
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins and staff see physicians for their hospital
|
# Hospital Admins and staff see physicians for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def performance(self, request, pk=None):
|
def performance(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
Get physician performance summary.
|
Get physician performance summary.
|
||||||
|
|
||||||
GET /api/physicians/{id}/performance/
|
GET /api/physicians/{id}/performance/
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- Current month rating
|
- Current month rating
|
||||||
- Previous month rating
|
- Previous month rating
|
||||||
@ -65,47 +65,47 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
- Trend analysis
|
- Trend analysis
|
||||||
"""
|
"""
|
||||||
physician = self.get_object()
|
physician = self.get_object()
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
current_year = now.year
|
current_year = now.year
|
||||||
current_month = now.month
|
current_month = now.month
|
||||||
|
|
||||||
# Get current month rating
|
# Get current month rating
|
||||||
current_month_rating = PhysicianMonthlyRating.objects.filter(
|
current_month_rating = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month
|
month=current_month
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# Get previous month rating
|
# Get previous month rating
|
||||||
prev_month = current_month - 1 if current_month > 1 else 12
|
prev_month = current_month - 1 if current_month > 1 else 12
|
||||||
prev_year = current_year if current_month > 1 else current_year - 1
|
prev_year = current_year if current_month > 1 else current_year - 1
|
||||||
previous_month_rating = PhysicianMonthlyRating.objects.filter(
|
previous_month_rating = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=prev_year,
|
year=prev_year,
|
||||||
month=prev_month
|
month=prev_month
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# Get year-to-date stats
|
# Get year-to-date stats
|
||||||
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician,
|
staff=physician,
|
||||||
year=current_year
|
year=current_year
|
||||||
)
|
)
|
||||||
|
|
||||||
ytd_stats = ytd_ratings.aggregate(
|
ytd_stats = ytd_ratings.aggregate(
|
||||||
avg_rating=Avg('average_rating'),
|
avg_rating=Avg('average_rating'),
|
||||||
total_surveys=Count('id')
|
total_surveys=Count('id')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get best and worst months (last 12 months)
|
# Get best and worst months (last 12 months)
|
||||||
last_12_months = PhysicianMonthlyRating.objects.filter(
|
last_12_months = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician
|
staff=physician
|
||||||
).order_by('-year', '-month')[:12]
|
).order_by('-year', '-month')[:12]
|
||||||
|
|
||||||
best_month = last_12_months.order_by('-average_rating').first()
|
best_month = last_12_months.order_by('-average_rating').first()
|
||||||
worst_month = last_12_months.order_by('average_rating').first()
|
worst_month = last_12_months.order_by('average_rating').first()
|
||||||
|
|
||||||
# Determine trend
|
# Determine trend
|
||||||
trend = 'stable'
|
trend = 'stable'
|
||||||
if current_month_rating and previous_month_rating:
|
if current_month_rating and previous_month_rating:
|
||||||
@ -113,7 +113,7 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
trend = 'improving'
|
trend = 'improving'
|
||||||
elif current_month_rating.average_rating < previous_month_rating.average_rating:
|
elif current_month_rating.average_rating < previous_month_rating.average_rating:
|
||||||
trend = 'declining'
|
trend = 'declining'
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
data = {
|
data = {
|
||||||
'physician': PhysicianSerializer(physician).data,
|
'physician': PhysicianSerializer(physician).data,
|
||||||
@ -125,26 +125,26 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
'worst_month': PhysicianMonthlyRatingSerializer(worst_month).data if worst_month else None,
|
'worst_month': PhysicianMonthlyRatingSerializer(worst_month).data if worst_month else None,
|
||||||
'trend': trend
|
'trend': trend
|
||||||
}
|
}
|
||||||
|
|
||||||
serializer = PhysicianPerformanceSerializer(data)
|
serializer = PhysicianPerformanceSerializer(data)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def ratings_history(self, request, pk=None):
|
def ratings_history(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
Get physician ratings history.
|
Get physician ratings history.
|
||||||
|
|
||||||
GET /api/physicians/{id}/ratings_history/?months=12
|
GET /api/physicians/{id}/ratings_history/?months=12
|
||||||
|
|
||||||
Returns monthly ratings for the specified number of months.
|
Returns monthly ratings for the specified number of months.
|
||||||
"""
|
"""
|
||||||
physician = self.get_object()
|
physician = self.get_object()
|
||||||
months = int(request.query_params.get('months', 12))
|
months = int(request.query_params.get('months', 12))
|
||||||
|
|
||||||
ratings = PhysicianMonthlyRating.objects.filter(
|
ratings = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=physician
|
staff=physician
|
||||||
).order_by('-year', '-month')[:months]
|
).order_by('-year', '-month')[:months]
|
||||||
|
|
||||||
serializer = PhysicianMonthlyRatingSerializer(ratings, many=True)
|
serializer = PhysicianMonthlyRatingSerializer(ratings, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@ -152,7 +152,7 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
|
class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Physician Monthly Ratings.
|
ViewSet for Physician Monthly Ratings.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- All authenticated users can view ratings
|
- All authenticated users can view ratings
|
||||||
- Filtered by hospital based on user role
|
- Filtered by hospital based on user role
|
||||||
@ -160,136 +160,136 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
queryset = PhysicianMonthlyRating.objects.all()
|
queryset = PhysicianMonthlyRating.objects.all()
|
||||||
serializer_class = PhysicianMonthlyRatingSerializer
|
serializer_class = PhysicianMonthlyRatingSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['physician', 'year', 'month', 'physician__hospital', 'physician__department']
|
filterset_fields = ['staff', 'year', 'month', 'staff__hospital', 'staff__department']
|
||||||
search_fields = ['physician__first_name', 'physician__last_name', 'physician__license_number']
|
search_fields = ['staff__first_name', 'staff__last_name', 'staff__license_number']
|
||||||
ordering_fields = ['year', 'month', 'average_rating', 'total_surveys', 'hospital_rank', 'department_rank']
|
ordering_fields = ['year', 'month', 'average_rating', 'total_surveys', 'hospital_rank', 'department_rank']
|
||||||
ordering = ['-year', '-month', '-average_rating']
|
ordering = ['-year', '-month', '-average_rating']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter ratings based on user role"""
|
"""Filter ratings based on user role"""
|
||||||
queryset = super().get_queryset().select_related(
|
queryset = super().get_queryset().select_related(
|
||||||
'physician',
|
'staff',
|
||||||
'physician__hospital',
|
'staff__hospital',
|
||||||
'physician__department'
|
'staff__department'
|
||||||
)
|
)
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all ratings
|
# PX Admins see all ratings
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins and staff see ratings for their hospital
|
# Hospital Admins and staff see ratings for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(physician__hospital=user.hospital)
|
return queryset.filter(staff__hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def leaderboard(self, request):
|
def leaderboard(self, request):
|
||||||
"""
|
"""
|
||||||
Get physician leaderboard.
|
Get physician leaderboard.
|
||||||
|
|
||||||
GET /api/physicians/ratings/leaderboard/?year=2024&month=12&hospital={id}&department={id}&limit=10
|
GET /api/physicians/ratings/leaderboard/?year=2024&month=12&hospital={id}&department={id}&limit=10
|
||||||
|
|
||||||
Returns top-rated physicians for the specified period.
|
Returns top-rated physicians for the specified period.
|
||||||
"""
|
"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
# Get parameters
|
# Get parameters
|
||||||
year = int(request.query_params.get('year', timezone.now().year))
|
year = int(request.query_params.get('year', timezone.now().year))
|
||||||
month = int(request.query_params.get('month', timezone.now().month))
|
month = int(request.query_params.get('month', timezone.now().month))
|
||||||
hospital_id = request.query_params.get('hospital')
|
hospital_id = request.query_params.get('hospital')
|
||||||
department_id = request.query_params.get('department')
|
department_id = request.query_params.get('department')
|
||||||
limit = int(request.query_params.get('limit', 10))
|
limit = int(request.query_params.get('limit', 10))
|
||||||
|
|
||||||
# Build queryset
|
# Build queryset
|
||||||
queryset = PhysicianMonthlyRating.objects.filter(
|
queryset = PhysicianMonthlyRating.objects.filter(
|
||||||
year=year,
|
year=year,
|
||||||
month=month
|
month=month
|
||||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(physician__hospital=user.hospital)
|
queryset = queryset.filter(staff__hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if hospital_id:
|
if hospital_id:
|
||||||
queryset = queryset.filter(physician__hospital_id=hospital_id)
|
queryset = queryset.filter(staff__hospital_id=hospital_id)
|
||||||
|
|
||||||
if department_id:
|
if department_id:
|
||||||
queryset = queryset.filter(physician__department_id=department_id)
|
queryset = queryset.filter(staff__department_id=department_id)
|
||||||
|
|
||||||
# Order by rating and limit
|
# Order by rating and limit
|
||||||
queryset = queryset.order_by('-average_rating')[:limit]
|
queryset = queryset.order_by('-average_rating')[:limit]
|
||||||
|
|
||||||
# Get previous month for trend
|
# Get previous month for trend
|
||||||
prev_month = month - 1 if month > 1 else 12
|
prev_month = month - 1 if month > 1 else 12
|
||||||
prev_year = year if month > 1 else year - 1
|
prev_year = year if month > 1 else year - 1
|
||||||
|
|
||||||
# Build leaderboard data
|
# Build leaderboard data
|
||||||
leaderboard = []
|
leaderboard = []
|
||||||
for rank, rating in enumerate(queryset, start=1):
|
for rank, rating in enumerate(queryset, start=1):
|
||||||
# Get previous month rating for trend
|
# Get previous month rating for trend
|
||||||
prev_rating = PhysicianMonthlyRating.objects.filter(
|
prev_rating = PhysicianMonthlyRating.objects.filter(
|
||||||
physician=rating.physician,
|
staff=rating.staff,
|
||||||
year=prev_year,
|
year=prev_year,
|
||||||
month=prev_month
|
month=prev_month
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
trend = 'stable'
|
trend = 'stable'
|
||||||
if prev_rating:
|
if prev_rating:
|
||||||
if rating.average_rating > prev_rating.average_rating:
|
if rating.average_rating > prev_rating.average_rating:
|
||||||
trend = 'up'
|
trend = 'up'
|
||||||
elif rating.average_rating < prev_rating.average_rating:
|
elif rating.average_rating < prev_rating.average_rating:
|
||||||
trend = 'down'
|
trend = 'down'
|
||||||
|
|
||||||
leaderboard.append({
|
leaderboard.append({
|
||||||
'physician_id': rating.physician.id,
|
'physician_id': rating.staff.id,
|
||||||
'physician_name': rating.physician.get_full_name(),
|
'physician_name': rating.staff.get_full_name(),
|
||||||
'physician_license': rating.physician.license_number,
|
'physician_license': rating.staff.license_number,
|
||||||
'specialization': rating.physician.specialization,
|
'specialization': rating.staff.specialization,
|
||||||
'department_name': rating.physician.department.name if rating.physician.department else '',
|
'department_name': rating.staff.department.name if rating.staff.department else '',
|
||||||
'average_rating': rating.average_rating,
|
'average_rating': rating.average_rating,
|
||||||
'total_surveys': rating.total_surveys,
|
'total_surveys': rating.total_surveys,
|
||||||
'rank': rank,
|
'rank': rank,
|
||||||
'trend': trend
|
'trend': trend
|
||||||
})
|
})
|
||||||
|
|
||||||
serializer = PhysicianLeaderboardSerializer(leaderboard, many=True)
|
serializer = PhysicianLeaderboardSerializer(leaderboard, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def statistics(self, request):
|
def statistics(self, request):
|
||||||
"""
|
"""
|
||||||
Get physician rating statistics.
|
Get physician rating statistics.
|
||||||
|
|
||||||
GET /api/physicians/ratings/statistics/?year=2024&month=12&hospital={id}
|
GET /api/physicians/ratings/statistics/?year=2024&month=12&hospital={id}
|
||||||
|
|
||||||
Returns aggregate statistics for the specified period.
|
Returns aggregate statistics for the specified period.
|
||||||
"""
|
"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
# Get parameters
|
# Get parameters
|
||||||
year = int(request.query_params.get('year', timezone.now().year))
|
year = int(request.query_params.get('year', timezone.now().year))
|
||||||
month = int(request.query_params.get('month', timezone.now().month))
|
month = int(request.query_params.get('month', timezone.now().month))
|
||||||
hospital_id = request.query_params.get('hospital')
|
hospital_id = request.query_params.get('hospital')
|
||||||
|
|
||||||
# Build queryset
|
# Build queryset
|
||||||
queryset = PhysicianMonthlyRating.objects.filter(
|
queryset = PhysicianMonthlyRating.objects.filter(
|
||||||
year=year,
|
year=year,
|
||||||
month=month
|
month=month
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply RBAC filters
|
# Apply RBAC filters
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and user.hospital:
|
||||||
queryset = queryset.filter(physician__hospital=user.hospital)
|
queryset = queryset.filter(staff__hospital=user.hospital)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if hospital_id:
|
if hospital_id:
|
||||||
queryset = queryset.filter(physician__hospital_id=hospital_id)
|
queryset = queryset.filter(staff__hospital_id=hospital_id)
|
||||||
|
|
||||||
# Calculate statistics
|
# Calculate statistics
|
||||||
stats = queryset.aggregate(
|
stats = queryset.aggregate(
|
||||||
total_physicians=Count('id'),
|
total_physicians=Count('id'),
|
||||||
@ -299,13 +299,13 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
total_neutral=Count('neutral_count'),
|
total_neutral=Count('neutral_count'),
|
||||||
total_negative=Count('negative_count')
|
total_negative=Count('negative_count')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get distribution
|
# Get distribution
|
||||||
excellent = queryset.filter(average_rating__gte=4.5).count()
|
excellent = queryset.filter(average_rating__gte=4.5).count()
|
||||||
good = queryset.filter(average_rating__gte=3.5, average_rating__lt=4.5).count()
|
good = queryset.filter(average_rating__gte=3.5, average_rating__lt=4.5).count()
|
||||||
average = queryset.filter(average_rating__gte=2.5, average_rating__lt=3.5).count()
|
average = queryset.filter(average_rating__gte=2.5, average_rating__lt=3.5).count()
|
||||||
poor = queryset.filter(average_rating__lt=2.5).count()
|
poor = queryset.filter(average_rating__lt=2.5).count()
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'year': year,
|
'year': year,
|
||||||
'month': month,
|
'month': month,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 11:25
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
@ -12,11 +12,27 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('organizations', '0001_initial'),
|
('organizations', '0001_initial'),
|
||||||
('px_action_center', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
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(
|
migrations.CreateModel(
|
||||||
name='QIProject',
|
name='QIProject',
|
||||||
fields=[
|
fields=[
|
||||||
@ -36,34 +52,9 @@ class Migration(migrations.Migration):
|
|||||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_projects', to='organizations.department')),
|
('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')),
|
('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)),
|
('project_lead', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_projects', to=settings.AUTH_USER_MODEL)),
|
||||||
('related_actions', models.ManyToManyField(blank=True, related_name='qi_projects', to='px_action_center.pxaction')),
|
|
||||||
('team_members', models.ManyToManyField(blank=True, related_name='qi_projects', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['-created_at'],
|
'ordering': ['-created_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
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)),
|
|
||||||
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_tasks', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.qiproject')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['project', 'order'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='qiproject',
|
|
||||||
index=models.Index(fields=['hospital', 'status', '-created_at'], name='projects_qi_hospita_e5dfc7_idx'),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
43
apps/projects/migrations/0002_initial.py
Normal file
43
apps/projects/migrations/0002_initial.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
|
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,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 11:11
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 11:19
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:16
|
# Generated by Django 5.0.14 on 2026-01-05 10:43
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -10,7 +11,9 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
('journeys', '0001_initial'),
|
||||||
('organizations', '0001_initial'),
|
('organizations', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -21,13 +24,40 @@ class Migration(migrations.Migration):
|
|||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=200)),
|
('name', models.CharField(max_length=200)),
|
||||||
('name_ar', models.CharField(blank=True, max_length=200)),
|
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
|
||||||
('description', models.TextField(blank=True)),
|
('description', models.TextField(blank=True)),
|
||||||
('is_active', models.BooleanField(default=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')),
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='survey_templates', to='organizations.hospital')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['name'],
|
'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(
|
migrations.CreateModel(
|
||||||
@ -37,14 +67,71 @@ class Migration(migrations.Migration):
|
|||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('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)),
|
('encounter_id', models.CharField(blank=True, db_index=True, max_length=100)),
|
||||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
('delivery_channel', models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email')], default='sms', max_length=20)),
|
||||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
('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)),
|
('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', 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')),
|
('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='surveys.surveytemplate')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['-created_at'],
|
'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'),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,196 +0,0 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-14 10:34
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('journeys', '0002_initial'),
|
|
||||||
('organizations', '0001_initial'),
|
|
||||||
('surveys', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
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)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['survey_template', 'order'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['survey_instance', 'question__order'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='surveytemplate',
|
|
||||||
options={'ordering': ['hospital', 'name']},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='access_token',
|
|
||||||
field=models.CharField(blank=True, db_index=True, help_text='Secure token for survey access', max_length=100, unique=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='delivery_channel',
|
|
||||||
field=models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email')], default='sms', max_length=20),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='is_negative',
|
|
||||||
field=models.BooleanField(db_index=True, default=False, help_text='True if score below threshold'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='journey_instance',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneyinstance'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='journey_stage_instance',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneystageinstance'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='metadata',
|
|
||||||
field=models.JSONField(blank=True, default=dict),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='opened_at',
|
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='recipient_email',
|
|
||||||
field=models.EmailField(blank=True, max_length=254),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='recipient_phone',
|
|
||||||
field=models.CharField(blank=True, max_length=20),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='token_expires_at',
|
|
||||||
field=models.DateTimeField(blank=True, help_text='Token expiration date', null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='total_score',
|
|
||||||
field=models.DecimalField(blank=True, decimal_places=2, help_text='Calculated total score', max_digits=5, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveytemplate',
|
|
||||||
name='description_ar',
|
|
||||||
field=models.TextField(blank=True, verbose_name='Description (Arabic)'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveytemplate',
|
|
||||||
name='negative_threshold',
|
|
||||||
field=models.DecimalField(decimal_places=1, default=3.0, help_text='Scores below this trigger PX actions (out of 5)', max_digits=3),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveytemplate',
|
|
||||||
name='scoring_method',
|
|
||||||
field=models.CharField(choices=[('average', 'Average Score'), ('weighted', 'Weighted Average'), ('nps', 'NPS Calculation')], default='average', max_length=20),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveytemplate',
|
|
||||||
name='survey_type',
|
|
||||||
field=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),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveytemplate',
|
|
||||||
name='version',
|
|
||||||
field=models.IntegerField(default=1),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='sent_at',
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='status',
|
|
||||||
field=models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='surveytemplate',
|
|
||||||
name='is_active',
|
|
||||||
field=models.BooleanField(db_index=True, default=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='surveytemplate',
|
|
||||||
name='name_ar',
|
|
||||||
field=models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)'),
|
|
||||||
),
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='surveytemplate',
|
|
||||||
index=models.Index(fields=['hospital', 'survey_type', 'is_active'], name='surveys_sur_hospita_0c8e30_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyquestion',
|
|
||||||
name='survey_template',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='surveys.surveytemplate'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyresponse',
|
|
||||||
name='question',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='responses', to='surveys.surveyquestion'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyresponse',
|
|
||||||
name='survey_instance',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='surveys.surveyinstance'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='surveyquestion',
|
|
||||||
index=models.Index(fields=['survey_template', 'order'], name='surveys_sur_survey__d8acd5_idx'),
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name='surveyresponse',
|
|
||||||
unique_together={('survey_instance', 'question')},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
# Generated by Django 5.0.14 on 2025-12-28 16:51
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('surveys', '0002_surveyquestion_surveyresponse_and_more'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='contact_notes',
|
|
||||||
field=models.TextField(blank=True, help_text='Notes from patient contact'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='issue_resolved',
|
|
||||||
field=models.BooleanField(default=False, help_text='Whether the issue was resolved/explained'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='patient_contacted',
|
|
||||||
field=models.BooleanField(default=False, help_text='Whether patient was contacted about negative survey'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='patient_contacted_at',
|
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='patient_contacted_by',
|
|
||||||
field=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),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='satisfaction_feedback_sent',
|
|
||||||
field=models.BooleanField(default=False, help_text='Whether satisfaction feedback form was sent'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='surveyinstance',
|
|
||||||
name='satisfaction_feedback_sent_at',
|
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -14,7 +14,7 @@ from django.core.signing import Signer
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.core.models import BaseChoices, StatusChoices, TimeStampedModel, UUIDModel
|
from apps.core.models import BaseChoices, StatusChoices, TenantModel, TimeStampedModel, UUIDModel
|
||||||
|
|
||||||
|
|
||||||
class QuestionType(BaseChoices):
|
class QuestionType(BaseChoices):
|
||||||
@ -31,7 +31,7 @@ class QuestionType(BaseChoices):
|
|||||||
class SurveyTemplate(UUIDModel, TimeStampedModel):
|
class SurveyTemplate(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Survey template defines questions for a survey.
|
Survey template defines questions for a survey.
|
||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
- Bilingual questions (AR/EN)
|
- Bilingual questions (AR/EN)
|
||||||
- Multiple question types
|
- Multiple question types
|
||||||
@ -42,14 +42,14 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
|
|||||||
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
|
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
hospital = models.ForeignKey(
|
hospital = models.ForeignKey(
|
||||||
'organizations.Hospital',
|
'organizations.Hospital',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='survey_templates'
|
related_name='survey_templates'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Survey type
|
# Survey type
|
||||||
survey_type = models.CharField(
|
survey_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -62,7 +62,7 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
|
|||||||
default='stage',
|
default='stage',
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Scoring configuration
|
# Scoring configuration
|
||||||
scoring_method = models.CharField(
|
scoring_method = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -79,22 +79,22 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
|
|||||||
default=3.0,
|
default=3.0,
|
||||||
help_text="Scores below this trigger PX actions (out of 5)"
|
help_text="Scores below this trigger PX actions (out of 5)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
is_active = models.BooleanField(default=True, db_index=True)
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
version = models.IntegerField(default=1)
|
version = models.IntegerField(default=1)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['hospital', 'name']
|
ordering = ['hospital', 'name']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['hospital', 'survey_type', 'is_active']),
|
models.Index(fields=['hospital', 'survey_type', 'is_active']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_question_count(self):
|
def get_question_count(self):
|
||||||
"""Get number of questions"""
|
"""Get number of questions"""
|
||||||
return self.questions.count()
|
return self.questions.count()
|
||||||
@ -103,7 +103,7 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
|
|||||||
class SurveyQuestion(UUIDModel, TimeStampedModel):
|
class SurveyQuestion(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Survey question within a template.
|
Survey question within a template.
|
||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
- Bilingual text (AR/EN)
|
- Bilingual text (AR/EN)
|
||||||
- Multiple question types
|
- Multiple question types
|
||||||
@ -115,11 +115,11 @@ class SurveyQuestion(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='questions'
|
related_name='questions'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Question text
|
# Question text
|
||||||
text = models.TextField(verbose_name="Question Text (English)")
|
text = models.TextField(verbose_name="Question Text (English)")
|
||||||
text_ar = models.TextField(blank=True, verbose_name="Question Text (Arabic)")
|
text_ar = models.TextField(blank=True, verbose_name="Question Text (Arabic)")
|
||||||
|
|
||||||
# Question configuration
|
# Question configuration
|
||||||
question_type = models.CharField(
|
question_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -128,14 +128,14 @@ class SurveyQuestion(UUIDModel, TimeStampedModel):
|
|||||||
)
|
)
|
||||||
order = models.IntegerField(default=0, help_text="Display order")
|
order = models.IntegerField(default=0, help_text="Display order")
|
||||||
is_required = models.BooleanField(default=True)
|
is_required = models.BooleanField(default=True)
|
||||||
|
|
||||||
# For multiple choice questions
|
# For multiple choice questions
|
||||||
choices_json = models.JSONField(
|
choices_json = models.JSONField(
|
||||||
default=list,
|
default=list,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]"
|
help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Scoring
|
# Scoring
|
||||||
weight = models.DecimalField(
|
weight = models.DecimalField(
|
||||||
max_digits=3,
|
max_digits=3,
|
||||||
@ -143,51 +143,53 @@ class SurveyQuestion(UUIDModel, TimeStampedModel):
|
|||||||
default=1.0,
|
default=1.0,
|
||||||
help_text="Weight for weighted average scoring"
|
help_text="Weight for weighted average scoring"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Branch logic
|
# Branch logic
|
||||||
branch_logic = models.JSONField(
|
branch_logic = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Conditional display logic: {'show_if': {'question_id': 'value'}}"
|
help_text="Conditional display logic: {'show_if': {'question_id': 'value'}}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Help text
|
# Help text
|
||||||
help_text = models.TextField(blank=True)
|
help_text = models.TextField(blank=True)
|
||||||
help_text_ar = models.TextField(blank=True)
|
help_text_ar = models.TextField(blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['survey_template', 'order']
|
ordering = ['survey_template', 'order']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['survey_template', 'order']),
|
models.Index(fields=['survey_template', 'order']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.survey_template.name} - Q{self.order}: {self.text[:50]}"
|
return f"{self.survey_template.name} - Q{self.order}: {self.text[:50]}"
|
||||||
|
|
||||||
|
|
||||||
class SurveyInstance(UUIDModel, TimeStampedModel):
|
class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
|
||||||
"""
|
"""
|
||||||
Survey instance - an actual survey sent to a patient.
|
Survey instance - an actual survey sent to a patient.
|
||||||
|
|
||||||
Linked to:
|
Linked to:
|
||||||
- Survey template (defines questions)
|
- Survey template (defines questions)
|
||||||
- Patient (recipient)
|
- Patient (recipient)
|
||||||
- Journey stage (optional - if stage survey)
|
- Journey stage (optional - if stage survey)
|
||||||
- Encounter (optional)
|
- Encounter (optional)
|
||||||
|
|
||||||
|
Tenant-aware: All surveys are scoped to a hospital.
|
||||||
"""
|
"""
|
||||||
survey_template = models.ForeignKey(
|
survey_template = models.ForeignKey(
|
||||||
SurveyTemplate,
|
SurveyTemplate,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='instances'
|
related_name='instances'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Patient information
|
# Patient information
|
||||||
patient = models.ForeignKey(
|
patient = models.ForeignKey(
|
||||||
'organizations.Patient',
|
'organizations.Patient',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='surveys'
|
related_name='surveys'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Journey linkage (for stage surveys)
|
# Journey linkage (for stage surveys)
|
||||||
journey_instance = models.ForeignKey(
|
journey_instance = models.ForeignKey(
|
||||||
'journeys.PatientJourneyInstance',
|
'journeys.PatientJourneyInstance',
|
||||||
@ -204,7 +206,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
related_name='surveys'
|
related_name='surveys'
|
||||||
)
|
)
|
||||||
encounter_id = models.CharField(max_length=100, blank=True, db_index=True)
|
encounter_id = models.CharField(max_length=100, blank=True, db_index=True)
|
||||||
|
|
||||||
# Delivery
|
# Delivery
|
||||||
delivery_channel = models.CharField(
|
delivery_channel = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -217,7 +219,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
)
|
)
|
||||||
recipient_phone = models.CharField(max_length=20, blank=True)
|
recipient_phone = models.CharField(max_length=20, blank=True)
|
||||||
recipient_email = models.EmailField(blank=True)
|
recipient_email = models.EmailField(blank=True)
|
||||||
|
|
||||||
# Access token for secure link
|
# Access token for secure link
|
||||||
access_token = models.CharField(
|
access_token = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
@ -231,7 +233,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Token expiration date"
|
help_text="Token expiration date"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -239,12 +241,12 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
default=StatusChoices.PENDING,
|
default=StatusChoices.PENDING,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||||
opened_at = models.DateTimeField(null=True, blank=True)
|
opened_at = models.DateTimeField(null=True, blank=True)
|
||||||
completed_at = models.DateTimeField(null=True, blank=True)
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
# Scoring
|
# Scoring
|
||||||
total_score = models.DecimalField(
|
total_score = models.DecimalField(
|
||||||
max_digits=5,
|
max_digits=5,
|
||||||
@ -258,10 +260,10 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="True if score below threshold"
|
help_text="True if score below threshold"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
# Patient contact tracking (for negative surveys)
|
# Patient contact tracking (for negative surveys)
|
||||||
patient_contacted = models.BooleanField(
|
patient_contacted = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
@ -284,14 +286,14 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
default=False,
|
default=False,
|
||||||
help_text="Whether the issue was resolved/explained"
|
help_text="Whether the issue was resolved/explained"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Satisfaction feedback tracking
|
# Satisfaction feedback tracking
|
||||||
satisfaction_feedback_sent = models.BooleanField(
|
satisfaction_feedback_sent = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text="Whether satisfaction feedback form was sent"
|
help_text="Whether satisfaction feedback form was sent"
|
||||||
)
|
)
|
||||||
satisfaction_feedback_sent_at = models.DateTimeField(null=True, blank=True)
|
satisfaction_feedback_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -299,15 +301,15 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
models.Index(fields=['status', '-sent_at']),
|
models.Index(fields=['status', '-sent_at']),
|
||||||
models.Index(fields=['is_negative', '-completed_at']),
|
models.Index(fields=['is_negative', '-completed_at']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.survey_template.name} - {self.patient.get_full_name()}"
|
return f"{self.survey_template.name} - {self.patient.get_full_name()}"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Generate access token on creation"""
|
"""Generate access token on creation"""
|
||||||
if not self.access_token:
|
if not self.access_token:
|
||||||
self.access_token = secrets.token_urlsafe(32)
|
self.access_token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
# Set token expiration
|
# Set token expiration
|
||||||
if not self.token_expires_at:
|
if not self.token_expires_at:
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@ -315,24 +317,24 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
days = getattr(settings, 'SURVEY_TOKEN_EXPIRY_DAYS', 30)
|
days = getattr(settings, 'SURVEY_TOKEN_EXPIRY_DAYS', 30)
|
||||||
self.token_expires_at = timezone.now() + timedelta(days=days)
|
self.token_expires_at = timezone.now() + timedelta(days=days)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_survey_url(self):
|
def get_survey_url(self):
|
||||||
"""Generate secure survey URL"""
|
"""Generate secure survey URL"""
|
||||||
# TODO: Implement in Phase 4 UI
|
# TODO: Implement in Phase 4 UI
|
||||||
return f"/surveys/{self.access_token}/"
|
return f"/surveys/{self.access_token}/"
|
||||||
|
|
||||||
def calculate_score(self):
|
def calculate_score(self):
|
||||||
"""
|
"""
|
||||||
Calculate total score from responses.
|
Calculate total score from responses.
|
||||||
|
|
||||||
Returns the calculated score and updates the instance.
|
Returns the calculated score and updates the instance.
|
||||||
"""
|
"""
|
||||||
responses = self.responses.all()
|
responses = self.responses.all()
|
||||||
if not responses.exists():
|
if not responses.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self.survey_template.scoring_method == 'average':
|
if self.survey_template.scoring_method == 'average':
|
||||||
# Simple average of all rating responses
|
# Simple average of all rating responses
|
||||||
rating_responses = responses.filter(
|
rating_responses = responses.filter(
|
||||||
@ -344,7 +346,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
score = total / count if count > 0 else 0
|
score = total / count if count > 0 else 0
|
||||||
else:
|
else:
|
||||||
score = 0
|
score = 0
|
||||||
|
|
||||||
elif self.survey_template.scoring_method == 'weighted':
|
elif self.survey_template.scoring_method == 'weighted':
|
||||||
# Weighted average based on question weights
|
# Weighted average based on question weights
|
||||||
total_weighted = 0
|
total_weighted = 0
|
||||||
@ -354,7 +356,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
total_weighted += float(response.numeric_value) * float(response.question.weight)
|
total_weighted += float(response.numeric_value) * float(response.question.weight)
|
||||||
total_weight += float(response.question.weight)
|
total_weight += float(response.question.weight)
|
||||||
score = total_weighted / total_weight if total_weight > 0 else 0
|
score = total_weighted / total_weight if total_weight > 0 else 0
|
||||||
|
|
||||||
else: # NPS
|
else: # NPS
|
||||||
# NPS calculation: % promoters - % detractors
|
# NPS calculation: % promoters - % detractors
|
||||||
nps_responses = responses.filter(question__question_type='nps')
|
nps_responses = responses.filter(question__question_type='nps')
|
||||||
@ -365,12 +367,12 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
|||||||
score = ((promoters - detractors) / total * 100) if total > 0 else 0
|
score = ((promoters - detractors) / total * 100) if total > 0 else 0
|
||||||
else:
|
else:
|
||||||
score = 0
|
score = 0
|
||||||
|
|
||||||
# Update instance
|
# Update instance
|
||||||
self.total_score = score
|
self.total_score = score
|
||||||
self.is_negative = score < float(self.survey_template.negative_threshold)
|
self.is_negative = score < float(self.survey_template.negative_threshold)
|
||||||
self.save(update_fields=['total_score', 'is_negative'])
|
self.save(update_fields=['total_score', 'is_negative'])
|
||||||
|
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
@ -388,7 +390,7 @@ class SurveyResponse(UUIDModel, TimeStampedModel):
|
|||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='responses'
|
related_name='responses'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Response value (type depends on question type)
|
# Response value (type depends on question type)
|
||||||
numeric_value = models.DecimalField(
|
numeric_value = models.DecimalField(
|
||||||
max_digits=10,
|
max_digits=10,
|
||||||
@ -406,17 +408,17 @@ class SurveyResponse(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="For multiple choice questions"
|
help_text="For multiple choice questions"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
response_time_seconds = models.IntegerField(
|
response_time_seconds = models.IntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Time taken to answer this question"
|
help_text="Time taken to answer this question"
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['survey_instance', 'question__order']
|
ordering = ['survey_instance', 'question__order']
|
||||||
unique_together = [['survey_instance', 'question']]
|
unique_together = [['survey_instance', 'question']]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.survey_instance} - {self.question.text[:30]}"
|
return f"{self.survey_instance} - {self.question.text[:30]}"
|
||||||
|
|||||||
@ -25,7 +25,7 @@ from .serializers import (
|
|||||||
class SurveyTemplateViewSet(viewsets.ModelViewSet):
|
class SurveyTemplateViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Survey Templates.
|
ViewSet for Survey Templates.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- PX Admins and Hospital Admins can manage templates
|
- PX Admins and Hospital Admins can manage templates
|
||||||
- Others can view templates
|
- Others can view templates
|
||||||
@ -33,35 +33,35 @@ class SurveyTemplateViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = SurveyTemplate.objects.all()
|
queryset = SurveyTemplate.objects.all()
|
||||||
serializer_class = SurveyTemplateSerializer
|
serializer_class = SurveyTemplateSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['survey_type', 'hospital', 'is_active']
|
filterset_fields = ['survey_type', 'hospital', 'is_active', 'hospital__organization']
|
||||||
search_fields = ['name', 'name_ar', 'description']
|
search_fields = ['name', 'name_ar', 'description']
|
||||||
ordering_fields = ['name', 'created_at']
|
ordering_fields = ['name', 'created_at']
|
||||||
ordering = ['hospital', 'name']
|
ordering = ['hospital', 'name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter templates based on user role"""
|
"""Filter templates based on user role"""
|
||||||
queryset = super().get_queryset().select_related('hospital').prefetch_related('questions')
|
queryset = super().get_queryset().select_related('hospital').prefetch_related('questions')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all templates
|
# PX Admins see all templates
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see templates for their hospital
|
# Hospital Admins see templates for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
# Others see templates for their hospital
|
# Others see templates for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(hospital=user.hospital)
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class SurveyQuestionViewSet(viewsets.ModelViewSet):
|
class SurveyQuestionViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Survey Questions.
|
ViewSet for Survey Questions.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- PX Admins and Hospital Admins can manage questions
|
- PX Admins and Hospital Admins can manage questions
|
||||||
"""
|
"""
|
||||||
@ -72,26 +72,26 @@ class SurveyQuestionViewSet(viewsets.ModelViewSet):
|
|||||||
search_fields = ['text', 'text_ar']
|
search_fields = ['text', 'text_ar']
|
||||||
ordering_fields = ['order', 'created_at']
|
ordering_fields = ['order', 'created_at']
|
||||||
ordering = ['survey_template', 'order']
|
ordering = ['survey_template', 'order']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset().select_related('survey_template')
|
queryset = super().get_queryset().select_related('survey_template')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all questions
|
# PX Admins see all questions
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see questions for their hospital
|
# Hospital Admins see questions for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(survey_template__hospital=user.hospital)
|
return queryset.filter(survey_template__hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class SurveyInstanceViewSet(viewsets.ModelViewSet):
|
class SurveyInstanceViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Survey Instances.
|
ViewSet for Survey Instances.
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- All authenticated users can view survey instances
|
- All authenticated users can view survey instances
|
||||||
- PX Admins and Hospital Admins can create/manage instances
|
- PX Admins and Hospital Admins can create/manage instances
|
||||||
@ -101,56 +101,57 @@ class SurveyInstanceViewSet(viewsets.ModelViewSet):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = [
|
filterset_fields = [
|
||||||
'survey_template', 'patient', 'status',
|
'survey_template', 'patient', 'status',
|
||||||
'delivery_channel', 'is_negative', 'journey_instance'
|
'delivery_channel', 'is_negative', 'journey_instance',
|
||||||
|
'survey_template__hospital__organization'
|
||||||
]
|
]
|
||||||
search_fields = ['patient__mrn', 'patient__first_name', 'patient__last_name', 'encounter_id']
|
search_fields = ['patient__mrn', 'patient__first_name', 'patient__last_name', 'encounter_id']
|
||||||
ordering_fields = ['sent_at', 'completed_at', 'created_at']
|
ordering_fields = ['sent_at', 'completed_at', 'created_at']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter survey instances based on user role"""
|
"""Filter survey instances based on user role"""
|
||||||
queryset = super().get_queryset().select_related(
|
queryset = super().get_queryset().select_related(
|
||||||
'survey_template', 'patient', 'journey_instance', 'journey_stage_instance'
|
'survey_template', 'patient', 'journey_instance', 'journey_stage_instance'
|
||||||
).prefetch_related('responses')
|
).prefetch_related('responses')
|
||||||
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all survey instances
|
# PX Admins see all survey instances
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see instances for their hospital
|
# Hospital Admins see instances for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(survey_template__hospital=user.hospital)
|
return queryset.filter(survey_template__hospital=user.hospital)
|
||||||
|
|
||||||
# Others see instances for their hospital
|
# Others see instances for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(survey_template__hospital=user.hospital)
|
return queryset.filter(survey_template__hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def resend(self, request, pk=None):
|
def resend(self, request, pk=None):
|
||||||
"""Resend survey invitation"""
|
"""Resend survey invitation"""
|
||||||
survey_instance = self.get_object()
|
survey_instance = self.get_object()
|
||||||
|
|
||||||
if survey_instance.status == 'completed':
|
if survey_instance.status == 'completed':
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Cannot resend completed survey'},
|
{'error': 'Cannot resend completed survey'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Queue survey send task
|
# Queue survey send task
|
||||||
from apps.surveys.tasks import send_survey_reminder
|
from apps.surveys.tasks import send_survey_reminder
|
||||||
send_survey_reminder.delay(str(survey_instance.id))
|
send_survey_reminder.delay(str(survey_instance.id))
|
||||||
|
|
||||||
return Response({'message': 'Survey invitation queued for resend'})
|
return Response({'message': 'Survey invitation queued for resend'})
|
||||||
|
|
||||||
|
|
||||||
class SurveyResponseViewSet(viewsets.ReadOnlyModelViewSet):
|
class SurveyResponseViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Survey Responses (read-only).
|
ViewSet for Survey Responses (read-only).
|
||||||
|
|
||||||
Responses are created via the public survey submission endpoint.
|
Responses are created via the public survey submission endpoint.
|
||||||
"""
|
"""
|
||||||
queryset = SurveyResponse.objects.all()
|
queryset = SurveyResponse.objects.all()
|
||||||
@ -158,38 +159,38 @@ class SurveyResponseViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ['survey_instance', 'question']
|
filterset_fields = ['survey_instance', 'question']
|
||||||
ordering = ['survey_instance', 'question__order']
|
ordering = ['survey_instance', 'question__order']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset().select_related('survey_instance', 'question')
|
queryset = super().get_queryset().select_related('survey_instance', 'question')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
# PX Admins see all responses
|
# PX Admins see all responses
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Hospital Admins see responses for their hospital
|
# Hospital Admins see responses for their hospital
|
||||||
if user.is_hospital_admin() and user.hospital:
|
if user.is_hospital_admin() and user.hospital:
|
||||||
return queryset.filter(survey_instance__survey_template__hospital=user.hospital)
|
return queryset.filter(survey_instance__survey_template__hospital=user.hospital)
|
||||||
|
|
||||||
# Others see responses for their hospital
|
# Others see responses for their hospital
|
||||||
if user.hospital:
|
if user.hospital:
|
||||||
return queryset.filter(survey_instance__survey_template__hospital=user.hospital)
|
return queryset.filter(survey_instance__survey_template__hospital=user.hospital)
|
||||||
|
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class PublicSurveyViewSet(viewsets.GenericViewSet):
|
class PublicSurveyViewSet(viewsets.GenericViewSet):
|
||||||
"""
|
"""
|
||||||
Public survey viewset for patient-facing survey access.
|
Public survey viewset for patient-facing survey access.
|
||||||
|
|
||||||
No authentication required - uses secure token.
|
No authentication required - uses secure token.
|
||||||
"""
|
"""
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
def retrieve(self, request, token=None):
|
def retrieve(self, request, token=None):
|
||||||
"""
|
"""
|
||||||
Get survey by access token.
|
Get survey by access token.
|
||||||
|
|
||||||
GET /api/surveys/public/{token}/
|
GET /api/surveys/public/{token}/
|
||||||
"""
|
"""
|
||||||
survey_instance = get_object_or_404(
|
survey_instance = get_object_or_404(
|
||||||
@ -198,36 +199,36 @@ class PublicSurveyViewSet(viewsets.GenericViewSet):
|
|||||||
),
|
),
|
||||||
access_token=token
|
access_token=token
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if token expired
|
# Check if token expired
|
||||||
if survey_instance.token_expires_at and survey_instance.token_expires_at < timezone.now():
|
if survey_instance.token_expires_at and survey_instance.token_expires_at < timezone.now():
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Survey link has expired'},
|
{'error': 'Survey link has expired'},
|
||||||
status=status.HTTP_410_GONE
|
status=status.HTTP_410_GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if already completed
|
# Check if already completed
|
||||||
if survey_instance.status == 'completed':
|
if survey_instance.status == 'completed':
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Survey already completed', 'completed_at': survey_instance.completed_at},
|
{'error': 'Survey already completed', 'completed_at': survey_instance.completed_at},
|
||||||
status=status.HTTP_410_GONE
|
status=status.HTTP_410_GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark as opened if first time
|
# Mark as opened if first time
|
||||||
if not survey_instance.opened_at:
|
if not survey_instance.opened_at:
|
||||||
survey_instance.opened_at = timezone.now()
|
survey_instance.opened_at = timezone.now()
|
||||||
survey_instance.save(update_fields=['opened_at'])
|
survey_instance.save(update_fields=['opened_at'])
|
||||||
|
|
||||||
serializer = PublicSurveySerializer(survey_instance)
|
serializer = PublicSurveySerializer(survey_instance)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='(?P<token>[^/.]+)/submit')
|
@action(detail=False, methods=['post'], url_path='(?P<token>[^/.]+)/submit')
|
||||||
def submit(self, request, token=None):
|
def submit(self, request, token=None):
|
||||||
"""
|
"""
|
||||||
Submit survey responses.
|
Submit survey responses.
|
||||||
|
|
||||||
POST /api/surveys/public/{token}/submit/
|
POST /api/surveys/public/{token}/submit/
|
||||||
|
|
||||||
Body:
|
Body:
|
||||||
{
|
{
|
||||||
"responses": [
|
"responses": [
|
||||||
@ -240,21 +241,21 @@ class PublicSurveyViewSet(viewsets.GenericViewSet):
|
|||||||
SurveyInstance,
|
SurveyInstance,
|
||||||
access_token=token
|
access_token=token
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if token expired
|
# Check if token expired
|
||||||
if survey_instance.token_expires_at and survey_instance.token_expires_at < timezone.now():
|
if survey_instance.token_expires_at and survey_instance.token_expires_at < timezone.now():
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Survey link has expired'},
|
{'error': 'Survey link has expired'},
|
||||||
status=status.HTTP_410_GONE
|
status=status.HTTP_410_GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if already completed
|
# Check if already completed
|
||||||
if survey_instance.status == 'completed':
|
if survey_instance.status == 'completed':
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Survey already completed'},
|
{'error': 'Survey already completed'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate and create responses
|
# Validate and create responses
|
||||||
serializer = SurveySubmissionSerializer(
|
serializer = SurveySubmissionSerializer(
|
||||||
data=request.data,
|
data=request.data,
|
||||||
@ -262,7 +263,7 @@ class PublicSurveyViewSet(viewsets.GenericViewSet):
|
|||||||
)
|
)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'message': 'Survey submitted successfully',
|
'message': 'Survey submitted successfully',
|
||||||
'score': float(survey_instance.total_score) if survey_instance.total_score else None
|
'score': float(survey_instance.total_score) if survey_instance.total_score else None
|
||||||
|
|||||||
@ -77,6 +77,7 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'apps.core.middleware.TenantMiddleware', # Multi-tenancy support
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
@ -96,6 +97,7 @@ TEMPLATES = [
|
|||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'django.template.context_processors.i18n',
|
'django.template.context_processors.i18n',
|
||||||
'apps.core.context_processors.sidebar_counts',
|
'apps.core.context_processors.sidebar_counts',
|
||||||
|
'apps.core.context_processors.hospital_context',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -317,6 +319,12 @@ SLA_DEFAULTS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# AI Configuration (LiteLLM with OpenRouter)
|
||||||
|
OPENROUTER_API_KEY = env('OPENROUTER_API_KEY', default='')
|
||||||
|
AI_MODEL = env('AI_MODEL', default='xiaomi/mimo-v2-flash:free')
|
||||||
|
AI_TEMPERATURE = env.float('AI_TEMPERATURE', default=0.3)
|
||||||
|
AI_MAX_TOKENS = env.int('AI_MAX_TOKENS', default=500)
|
||||||
|
|
||||||
# Notification Configuration
|
# Notification Configuration
|
||||||
NOTIFICATION_CHANNELS = {
|
NOTIFICATION_CHANNELS = {
|
||||||
'sms': {
|
'sms': {
|
||||||
@ -346,3 +354,13 @@ DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@px360.sa')
|
|||||||
SECURE_BROWSER_XSS_FILTER = True
|
SECURE_BROWSER_XSS_FILTER = True
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
X_FRAME_OPTIONS = 'DENY'
|
X_FRAME_OPTIONS = 'DENY'
|
||||||
|
|
||||||
|
# Multi-Tenancy Settings
|
||||||
|
TENANCY_ENABLED = True
|
||||||
|
TENANT_MODEL = 'organizations.Hospital'
|
||||||
|
TENANT_FIELD = 'hospital'
|
||||||
|
|
||||||
|
# Tenant isolation level
|
||||||
|
# 'strict' - Complete isolation (users only see their hospital)
|
||||||
|
# 'relaxed' - PX admins can see all hospitals
|
||||||
|
TENANT_ISOLATION_LEVEL = 'strict'
|
||||||
|
|||||||
180
docs/BILINGUAL_AI_ANALYSIS.md
Normal file
180
docs/BILINGUAL_AI_ANALYSIS.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
# Bilingual AI Analysis Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The AI analysis system now generates all text fields in both English and Arabic, providing better support for bilingual users in Saudi Arabia.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. AI Service (`apps/core/ai_service.py`)
|
||||||
|
|
||||||
|
**Updated AI Prompt:**
|
||||||
|
- Modified the `analyze_complaint` method to request bilingual output
|
||||||
|
- AI now generates all text fields in both English and Arabic
|
||||||
|
- System prompt emphasizes bilingual capabilities
|
||||||
|
|
||||||
|
**New Response Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title_en": "concise title in English summarizing the complaint (max 10 words)",
|
||||||
|
"title_ar": "العنوان بالعربية",
|
||||||
|
"short_description_en": "2-3 sentence summary in English of the complaint that captures the main issue and context",
|
||||||
|
"short_description_ar": "ملخص من 2-3 جمل بالعربية",
|
||||||
|
"severity": "low|medium|high|critical",
|
||||||
|
"priority": "low|medium|high",
|
||||||
|
"category": "exact category name from the list above",
|
||||||
|
"subcategory": "exact subcategory name from the chosen category, or empty string if not applicable",
|
||||||
|
"department": "exact department name from the hospital's departments, or empty string if not applicable",
|
||||||
|
"suggested_action_en": "2-3 specific, actionable steps in English to address this complaint",
|
||||||
|
"suggested_action_ar": "خطوات محددة وعمليه بالعربية",
|
||||||
|
"reasoning_en": "Brief explanation in English of your classification (2-3 sentences)",
|
||||||
|
"reasoning_ar": "شرح مختصر بالعربية"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Complaint Tasks (`apps/complaints/tasks.py`)
|
||||||
|
|
||||||
|
**Updated `analyze_complaint_with_ai` Task:**
|
||||||
|
- Stores bilingual AI results in complaint metadata
|
||||||
|
- Updates complaint title from AI's English version
|
||||||
|
- Creates bilingual timeline update messages
|
||||||
|
- Returns bilingual results
|
||||||
|
|
||||||
|
**Metadata Structure:**
|
||||||
|
```python
|
||||||
|
complaint.metadata['ai_analysis'] = {
|
||||||
|
'title_en': 'English title',
|
||||||
|
'title_ar': 'العنوان بالعربية',
|
||||||
|
'short_description_en': 'English summary',
|
||||||
|
'short_description_ar': 'ملخص بالعربية',
|
||||||
|
'suggested_action_en': 'English action steps',
|
||||||
|
'suggested_action_ar': 'خطوات العمل بالعربية',
|
||||||
|
'reasoning_en': 'English explanation',
|
||||||
|
'reasoning_ar': 'الشرح بالعربية',
|
||||||
|
'analyzed_at': 'ISO timestamp',
|
||||||
|
'old_severity': 'previous severity',
|
||||||
|
'old_priority': 'previous priority',
|
||||||
|
'old_category': 'previous category name',
|
||||||
|
'old_category_id': 'previous category ID',
|
||||||
|
'old_department': 'previous department name',
|
||||||
|
'old_department_id': 'previous department ID'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timeline Update Example:**
|
||||||
|
```
|
||||||
|
AI analysis complete: Severity=high, Priority=medium, Category=Quality of Care, Department=Nursing
|
||||||
|
|
||||||
|
اكتمل تحليل الذكاء الاصطناعي: الشدة=high, الأولوية=medium, الفئة=Quality of Care, القسم=Nursing
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Complaint Model (`apps/complaints/models.py`)
|
||||||
|
|
||||||
|
**Added Bilingual Properties:**
|
||||||
|
- `title_en` - AI-generated title (English)
|
||||||
|
- `title_ar` - AI-generated title (Arabic)
|
||||||
|
- `short_description_en` - AI-generated short description (English)
|
||||||
|
- `short_description_ar` - AI-generated short description (Arabic)
|
||||||
|
- `suggested_action_en` - AI-generated suggested action (English)
|
||||||
|
- `suggested_action_ar` - AI-generated suggested action (Arabic)
|
||||||
|
- `reasoning_en` - AI-generated reasoning (English)
|
||||||
|
- `reasoning_ar` - AI-generated reasoning (Arabic)
|
||||||
|
|
||||||
|
**Backward Compatibility:**
|
||||||
|
- Existing properties `short_description` and `suggested_action` still work
|
||||||
|
- They now return the English versions (`short_description_en`, `suggested_action_en`)
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Accessing Bilingual AI Results in Code
|
||||||
|
|
||||||
|
```python
|
||||||
|
complaint = Complaint.objects.get(id=some_id)
|
||||||
|
|
||||||
|
# Get English version
|
||||||
|
title_en = complaint.title_en
|
||||||
|
summary_en = complaint.short_description_en
|
||||||
|
action_en = complaint.suggested_action_en
|
||||||
|
|
||||||
|
# Get Arabic version
|
||||||
|
title_ar = complaint.title_ar
|
||||||
|
summary_ar = complaint.short_description_ar
|
||||||
|
action_ar = complaint.suggested_action_ar
|
||||||
|
|
||||||
|
# Get reasoning
|
||||||
|
reasoning_en = complaint.reasoning_en
|
||||||
|
reasoning_ar = complaint.reasoning_ar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Raw Metadata
|
||||||
|
|
||||||
|
```python
|
||||||
|
ai_analysis = complaint.metadata.get('ai_analysis', {})
|
||||||
|
|
||||||
|
if ai_analysis:
|
||||||
|
title_en = ai_analysis.get('title_en', '')
|
||||||
|
title_ar = ai_analysis.get('title_ar', '')
|
||||||
|
# etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Displaying in Templates
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- English version -->
|
||||||
|
<h2>{{ complaint.title_en }}</h2>
|
||||||
|
<p>{{ complaint.short_description_en }}</p>
|
||||||
|
|
||||||
|
<!-- Arabic version (with RTL) -->
|
||||||
|
<h2 dir="rtl">{{ complaint.title_ar }}</h2>
|
||||||
|
<p dir="rtl">{{ complaint.short_description_ar }}</p>
|
||||||
|
|
||||||
|
<!-- Language-aware display -->
|
||||||
|
{% if LANGUAGE_CODE == 'ar' %}
|
||||||
|
<h2 dir="rtl">{{ complaint.title_ar }}</h2>
|
||||||
|
{% else %}
|
||||||
|
<h2>{{ complaint.title_en }}</h2>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fields That Are Bilingual
|
||||||
|
|
||||||
|
- ✅ Title
|
||||||
|
- ✅ Short description
|
||||||
|
- ✅ Suggested action
|
||||||
|
- ✅ Reasoning
|
||||||
|
|
||||||
|
## Fields That Remain Single Values
|
||||||
|
|
||||||
|
- Severity (enum code: low/medium/high/critical)
|
||||||
|
- Priority (enum code: low/medium/high)
|
||||||
|
- Category (reference to existing category)
|
||||||
|
- Subcategory (reference to existing subcategory)
|
||||||
|
- Department (reference to existing department)
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- No database migration required (metadata is JSONField)
|
||||||
|
- Existing complaints will have empty `_en` and `_ar` fields until re-analyzed
|
||||||
|
- Backward compatible - existing code using `short_description` and `suggested_action` will continue to work
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To test bilingual AI analysis:
|
||||||
|
|
||||||
|
1. Create a new complaint via the public form
|
||||||
|
2. Wait for AI analysis (async Celery task)
|
||||||
|
3. Check the complaint metadata:
|
||||||
|
```python
|
||||||
|
complaint = Complaint.objects.latest('created_at')
|
||||||
|
print(complaint.metadata['ai_analysis'])
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output should include both `_en` and `_ar` versions of all text fields.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Possible future improvements:
|
||||||
|
1. Add language preference per user
|
||||||
|
2. Auto-display based on user's language setting
|
||||||
|
3. Add translation for existing complaints
|
||||||
|
4. Support for more languages if needed
|
||||||
335
docs/EMOTION_ANALYSIS.md
Normal file
335
docs/EMOTION_ANALYSIS.md
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
# Emotion Analysis Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The AI service now performs emotion analysis on complaints, identifying the primary emotion (Anger, Sadness, Confusion, Fear, or Neutral) with an intensity score (0.0 to 1.0) and confidence score (0.0 to 1.0). This helps staff better understand the emotional state of patients and prioritize responses accordingly.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. AI Service (`apps/core/ai_service.py`)
|
||||||
|
|
||||||
|
**New Method: `analyze_emotion`**
|
||||||
|
|
||||||
|
Analyzes text to identify the primary emotion and its intensity.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
- `text`: Text to analyze (supports both English and Arabic)
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
'emotion': 'anger' | 'sadness' | 'confusion' | 'fear' | 'neutral',
|
||||||
|
'intensity': float, # 0.0 to 1.0 (how strong the emotion is)
|
||||||
|
'confidence': float # 0.0 to 1.0 (how confident AI is)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Emotion Categories:**
|
||||||
|
- **anger**: Strong feelings of displeasure, hostility, or rage
|
||||||
|
- **sadness**: Feelings of sorrow, grief, or unhappiness
|
||||||
|
- **confusion**: Lack of understanding, bewilderment, or uncertainty
|
||||||
|
- **fear**: Feelings of anxiety, worry, or being afraid
|
||||||
|
- **neutral**: No strong emotion detected
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Bilingual support (English and Arabic)
|
||||||
|
- Input validation for intensity and confidence scores
|
||||||
|
- Automatic clamping to valid range (0.0 to 1.0)
|
||||||
|
- Detailed logging for debugging
|
||||||
|
|
||||||
|
### 2. Complaint Tasks (`apps/complaints/tasks.py`)
|
||||||
|
|
||||||
|
**Updated `analyze_complaint_with_ai` Task:**
|
||||||
|
|
||||||
|
Now performs both standard AI analysis AND emotion analysis:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Analyze complaint using AI service
|
||||||
|
analysis = AIService.analyze_complaint(...)
|
||||||
|
|
||||||
|
# Analyze emotion using AI service
|
||||||
|
emotion_analysis = AIService.analyze_emotion(text=complaint.description)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Metadata Storage:**
|
||||||
|
Emotion analysis is stored in complaint metadata:
|
||||||
|
```python
|
||||||
|
complaint.metadata['ai_analysis'] = {
|
||||||
|
# ... other fields ...
|
||||||
|
'emotion': emotion_analysis.get('emotion', 'neutral'),
|
||||||
|
'emotion_intensity': emotion_analysis.get('intensity', 0.0),
|
||||||
|
'emotion_confidence': emotion_analysis.get('confidence', 0.0),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timeline Update:**
|
||||||
|
Emotion is included in the bilingual AI analysis timeline message:
|
||||||
|
```
|
||||||
|
AI analysis complete: Severity=high, Priority=medium, Category=Quality of Care,
|
||||||
|
Department=Nursing, Emotion=anger (Intensity: 0.85)
|
||||||
|
|
||||||
|
اكتمل تحليل الذكاء الاصطناعي: الشدة=high, الأولوية=medium, الفئة=Quality of Care,
|
||||||
|
القسم=Nursing, العاطفة=anger (الشدة: 0.85)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Complaint Model (`apps/complaints/models.py`)
|
||||||
|
|
||||||
|
**Added Properties:**
|
||||||
|
- `emotion` - Primary emotion (anger/sadness/confusion/fear/neutral)
|
||||||
|
- `emotion_intensity` - Intensity score (0.0 to 1.0)
|
||||||
|
- `emotion_confidence` - Confidence score (0.0 to 1.0)
|
||||||
|
- `get_emotion_display` - Human-readable emotion name
|
||||||
|
- `get_emotion_badge_class` - Bootstrap badge class for emotion
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```python
|
||||||
|
complaint = Complaint.objects.get(id=some_id)
|
||||||
|
|
||||||
|
# Get emotion data
|
||||||
|
emotion = complaint.emotion # 'anger'
|
||||||
|
intensity = complaint.emotion_intensity # 0.85
|
||||||
|
confidence = complaint.emotion_confidence # 0.92
|
||||||
|
|
||||||
|
# Get display values
|
||||||
|
display_name = complaint.get_emotion_display # 'Anger'
|
||||||
|
badge_class = complaint.get_emotion_badge_class # 'danger'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Badge Color Mapping:**
|
||||||
|
- Anger → `danger` (red)
|
||||||
|
- Sadness → `primary` (blue)
|
||||||
|
- Confusion → `warning` (yellow)
|
||||||
|
- Fear → `info` (cyan)
|
||||||
|
- Neutral → `secondary` (gray)
|
||||||
|
|
||||||
|
### 4. Complaint Detail Template (`templates/complaints/complaint_detail.html`)
|
||||||
|
|
||||||
|
**New Section: Emotion Analysis**
|
||||||
|
|
||||||
|
Added to the AI Analysis section in the Details tab:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="info-label">Emotion Analysis</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Emotion Badge -->
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-emoji-frown"></i> Anger
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Confidence -->
|
||||||
|
<small class="text-muted">Confidence: 92%</small>
|
||||||
|
|
||||||
|
<!-- Intensity Progress Bar -->
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar bg-danger" style="width: 85%"></div>
|
||||||
|
</div>
|
||||||
|
<small>Intensity: 0.85 / 1.0</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual Features:**
|
||||||
|
- Color-coded badge based on emotion type
|
||||||
|
- Progress bar showing intensity (0.0 to 1.0)
|
||||||
|
- Confidence percentage display
|
||||||
|
- Gradient background card design
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Accessing Emotion Data in Code
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.complaints.models import Complaint
|
||||||
|
|
||||||
|
# Get a complaint
|
||||||
|
complaint = Complaint.objects.get(id=complaint_id)
|
||||||
|
|
||||||
|
# Check if emotion analysis exists
|
||||||
|
if complaint.emotion:
|
||||||
|
print(f"Primary emotion: {complaint.get_emotion_display}")
|
||||||
|
print(f"Intensity: {complaint.emotion_intensity:.2f}")
|
||||||
|
print(f"Confidence: {complaint.emotion_confidence:.2f}")
|
||||||
|
|
||||||
|
# Check for high-intensity anger (may need escalation)
|
||||||
|
if complaint.emotion == 'anger' and complaint.emotion_intensity > 0.8:
|
||||||
|
print("⚠️ High-intensity anger detected - consider escalation")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Raw Metadata
|
||||||
|
|
||||||
|
```python
|
||||||
|
ai_analysis = complaint.metadata.get('ai_analysis', {})
|
||||||
|
|
||||||
|
if ai_analysis:
|
||||||
|
emotion = ai_analysis.get('emotion', 'neutral')
|
||||||
|
intensity = ai_analysis.get('emotion_intensity', 0.0)
|
||||||
|
confidence = ai_analysis.get('emotion_confidence', 0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Displaying in Templates
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Basic display -->
|
||||||
|
<div>Emotion: {{ complaint.get_emotion_display }}</div>
|
||||||
|
<div>Intensity: {{ complaint.emotion_intensity|floatformat:2 }}</div>
|
||||||
|
<div>Confidence: {{ complaint.emotion_confidence|floatformat:2 }}</div>
|
||||||
|
|
||||||
|
<!-- With badge -->
|
||||||
|
<span class="badge bg-{{ complaint.get_emotion_badge_class }}">
|
||||||
|
{{ complaint.get_emotion_display }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar bg-{{ complaint.get_emotion_badge_class }}"
|
||||||
|
style="width: {{ complaint.emotion_intensity|mul:100 }}%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### 1. Prioritization
|
||||||
|
|
||||||
|
High-intensity anger complaints can be prioritized:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get high-intensity anger complaints
|
||||||
|
urgent_complaints = Complaint.objects.filter(
|
||||||
|
metadata__ai_analysis__emotion='anger',
|
||||||
|
metadata__ai_analysis__emotion_intensity__gte=0.7
|
||||||
|
).order_by('-metadata__ai_analysis__emotion_intensity')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Automatic Escalation
|
||||||
|
|
||||||
|
Trigger escalation for high-intensity emotions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if complaint.emotion == 'anger' and complaint.emotion_intensity > 0.8:
|
||||||
|
escalate_complaint_auto.delay(str(complaint.id))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Staff Assignment
|
||||||
|
|
||||||
|
Assign complaints with negative emotions to more experienced staff:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if complaint.emotion in ['anger', 'fear']:
|
||||||
|
# Assign to senior staff
|
||||||
|
complaint.assigned_to = get_senior_staff_member()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Analytics
|
||||||
|
|
||||||
|
Track emotion trends over time:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
# Get emotion distribution
|
||||||
|
complaints = Complaint.objects.filter(
|
||||||
|
created_at__gte=start_date
|
||||||
|
)
|
||||||
|
|
||||||
|
emotion_counts = Counter([
|
||||||
|
c.emotion for c in complaints if c.emotion
|
||||||
|
])
|
||||||
|
|
||||||
|
# Result: {'anger': 15, 'sadness': 8, 'confusion': 12, 'fear': 5, 'neutral': 20}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Emotion Examples
|
||||||
|
|
||||||
|
| Emotion | Example Text | Intensity | Badge |
|
||||||
|
|---------|--------------|------------|-------|
|
||||||
|
| Anger | "This is unacceptable! I demand to speak to management!" | 0.9 | 🔴 Danger |
|
||||||
|
| Sadness | "I'm very disappointed with the care my father received" | 0.7 | 🔵 Primary |
|
||||||
|
| Confusion | "I don't understand what happened, can you explain?" | 0.5 | 🟡 Warning |
|
||||||
|
| Fear | "I'm worried about the side effects of this medication" | 0.6 | 🔵 Info |
|
||||||
|
| Neutral | "I would like to report a minor issue" | 0.2 | ⚫ Secondary |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To test emotion analysis:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.core.ai_service import AIService
|
||||||
|
|
||||||
|
# Test English text
|
||||||
|
result = AIService.analyze_emotion(
|
||||||
|
"This is unacceptable! I demand to speak to management!"
|
||||||
|
)
|
||||||
|
print(result)
|
||||||
|
# Output: {'emotion': 'anger', 'intensity': 0.9, 'confidence': 0.95}
|
||||||
|
|
||||||
|
# Test Arabic text
|
||||||
|
result = AIService.analyze_emotion(
|
||||||
|
"أنا قلق جداً من الآثار الجانبية لهذا الدواء"
|
||||||
|
)
|
||||||
|
print(result)
|
||||||
|
# Output: {'emotion': 'fear', 'intensity': 0.7, 'confidence': 0.88}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Better Understanding**: Identifies specific emotions instead of generic sentiment
|
||||||
|
2. **Intensity Tracking**: Shows how strong the emotion is (helps prioritize)
|
||||||
|
3. **Bilingual Support**: Works with both English and Arabic complaints
|
||||||
|
4. **Visual Feedback**: Easy-to-read badges and progress bars
|
||||||
|
5. **Actionable**: High-intensity emotions can trigger automatic responses
|
||||||
|
6. **Confidence Scoring**: Knows how reliable the analysis is
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Possible future improvements:
|
||||||
|
1. Add emotion trend tracking for patients
|
||||||
|
2. Create dashboard visualizations for emotion statistics
|
||||||
|
3. Add more emotion categories (frustration, joy, surprise)
|
||||||
|
4. Emotion-based routing to specialized staff
|
||||||
|
5. Patient emotion profiles over time
|
||||||
|
6. Integration with CRM systems
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### AIService.analyze_emotion(text)
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `text` (str): Text to analyze
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- `dict`: Emotion analysis with keys:
|
||||||
|
- `emotion` (str): Primary emotion category
|
||||||
|
- `intensity` (float): Emotion strength (0.0 to 1.0)
|
||||||
|
- `confidence` (float): AI confidence (0.0 to 1.0)
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
- `AIServiceError`: If API call fails
|
||||||
|
|
||||||
|
### Complaint.emotion (property)
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- `str`: Primary emotion code
|
||||||
|
|
||||||
|
### Complaint.emotion_intensity (property)
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- `float`: Emotion intensity (0.0 to 1.0)
|
||||||
|
|
||||||
|
### Complaint.emotion_confidence (property)
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- `float`: AI confidence in emotion detection (0.0 to 1.0)
|
||||||
|
|
||||||
|
### Complaint.get_emotion_display (property)
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- `str`: Human-readable emotion name
|
||||||
|
|
||||||
|
### Complaint.get_emotion_badge_class (property)
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- `str`: Bootstrap badge class for emotion
|
||||||
209
docs/HOSPITAL_HEADER_DISPLAY.md
Normal file
209
docs/HOSPITAL_HEADER_DISPLAY.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
# Hospital Sidebar Display Feature
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes hospital display feature in PX360 sidebar, which shows the current hospital context and allows PX Admins to quickly switch between hospitals.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Hospital Display in Sidebar
|
||||||
|
|
||||||
|
The sidebar now displays the current hospital at the bottom:
|
||||||
|
- Hospital icon (🏥)
|
||||||
|
- Hospital name (truncated if too long)
|
||||||
|
- City for context
|
||||||
|
- Dropdown for PX Admins to switch hospitals
|
||||||
|
- Read-only display for Hospital Admins and Department Managers
|
||||||
|
|
||||||
|
**Position**: At the bottom of the sidebar, after all navigation items.
|
||||||
|
|
||||||
|
### 2. PX Admin Quick Switch
|
||||||
|
|
||||||
|
For PX Admins, clicking on the hospital display opens a dropdown with:
|
||||||
|
- List of all hospitals in the system
|
||||||
|
- Current hospital highlighted with a checkmark
|
||||||
|
- Hospital name and city for each option
|
||||||
|
- "View All Hospitals" link to the full hospital selector page
|
||||||
|
|
||||||
|
### 3. Hospital Context Availability
|
||||||
|
|
||||||
|
The current hospital is available in all templates through the `current_hospital` context variable, provided by the `hospital_context` context processor.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. **`apps/core/context_processors.py`**
|
||||||
|
- Added `hospital_context()` function
|
||||||
|
- Provides `current_hospital` and `is_px_admin` to all templates
|
||||||
|
- Also updated `sidebar_counts()` to include hospital context
|
||||||
|
|
||||||
|
2. **`config/settings/base.py`**
|
||||||
|
- Added `apps.core.context_processors.hospital_context` to context_processors list
|
||||||
|
|
||||||
|
3. **`templates/layouts/partials/sidebar.html`**
|
||||||
|
- Added hospital display component at the top of sidebar (after brand)
|
||||||
|
- Includes dropdown for PX Admins with all hospitals
|
||||||
|
- Forms for quick hospital switching
|
||||||
|
|
||||||
|
4. **`apps/core/templatetags/hospital_filters.py`** (new)
|
||||||
|
- Created `get_all_hospitals` template tag
|
||||||
|
- Returns all hospitals ordered by name, city
|
||||||
|
|
||||||
|
5. **`apps/core/templatetags/__init__.py`** (new)
|
||||||
|
- Enables template tag module for core app
|
||||||
|
|
||||||
|
### Context Variables
|
||||||
|
|
||||||
|
Available in all templates:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
'current_hospital': Hospital, # Current hospital object or None
|
||||||
|
'is_px_admin': bool, # True if user is PX Admin
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### For PX Admins
|
||||||
|
|
||||||
|
1. **Initial State**: Shows current hospital with dropdown indicator
|
||||||
|
2. **Click Dropdown**: Opens list of all hospitals
|
||||||
|
3. **Select Hospital**:
|
||||||
|
- Submits form to select hospital
|
||||||
|
- Session is updated
|
||||||
|
- Page refreshes with new hospital data
|
||||||
|
- Hospital display updates to show new selection
|
||||||
|
4. **Alternative**: Can click "View All Hospitals" to see full hospital selector page
|
||||||
|
|
||||||
|
### For Hospital Admins
|
||||||
|
|
||||||
|
1. **Display**: Shows their assigned hospital (read-only)
|
||||||
|
2. **No Dropdown**: Cannot switch hospitals
|
||||||
|
3. **Clear Context**: Always know which hospital data they're viewing
|
||||||
|
|
||||||
|
### For Department Managers
|
||||||
|
|
||||||
|
1. **Display**: Shows their hospital (read-only)
|
||||||
|
2. **No Dropdown**: Cannot switch hospitals
|
||||||
|
3. **Consistent View**: Hospital context is clear
|
||||||
|
|
||||||
|
## Template Usage
|
||||||
|
|
||||||
|
### Accessing Hospital in Templates
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% if current_hospital %}
|
||||||
|
<h2>{{ current_hospital.name }}</h2>
|
||||||
|
<p>{{ current_hospital.city }}</p>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking User Role
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% if is_px_admin %}
|
||||||
|
<p>You are a PX Admin with access to all hospitals</p>
|
||||||
|
{% else %}
|
||||||
|
<p>You are viewing: {{ current_hospital.name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Hospital Filter
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% load hospital_filters %}
|
||||||
|
|
||||||
|
{% get_all_hospitals as hospitals %}
|
||||||
|
{% for hospital in hospitals %}
|
||||||
|
{{ hospital.name }}
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
The hospital display uses Bootstrap 5 classes with custom styling:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.btn-light {
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
- Hospital display is visible on desktop (md and up)
|
||||||
|
- On mobile, it may need adjustment or can be hidden
|
||||||
|
- Dropdown has max-height with scrollbar for many hospitals
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Recent Hospitals**: Show recently accessed hospitals first
|
||||||
|
2. **Hospital Search**: Add search box within dropdown for many hospitals
|
||||||
|
3. **Hospital Avatar**: Display hospital logo/image instead of icon
|
||||||
|
4. **Hospital Status**: Show active/inactive status indicators
|
||||||
|
5. **Hospital Stats**: Display quick stats (complaints, surveys, etc.)
|
||||||
|
6. **Mobile Optimization**: Better mobile experience for hospital switching
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Hospital displays correctly for all user types
|
||||||
|
- [ ] PX Admin can see dropdown with all hospitals
|
||||||
|
- [ ] PX Admin can switch hospitals successfully
|
||||||
|
- [ ] Hospital Admin sees only their hospital (no dropdown)
|
||||||
|
- [ ] Department Manager sees their hospital (no dropdown)
|
||||||
|
- [ ] Hospital name truncates properly if too long
|
||||||
|
- [ ] Dropdown shows checkmark for current hospital
|
||||||
|
- [ ] "View All Hospitals" link works
|
||||||
|
- [ ] Session updates after hospital switch
|
||||||
|
- [ ] Page refreshes with correct hospital data
|
||||||
|
- [ ] Hospital context available in all templates
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Hospital Not Displaying
|
||||||
|
|
||||||
|
**Problem**: Hospital display doesn't show in sidebar.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check that user is authenticated
|
||||||
|
2. Verify `TenantMiddleware` is setting `request.tenant_hospital`
|
||||||
|
3. Ensure `hospital_context` is in settings context_processors
|
||||||
|
4. Check browser console for template errors
|
||||||
|
|
||||||
|
### Dropdown Not Working
|
||||||
|
|
||||||
|
**Problem**: PX Admin sees hospital but dropdown doesn't open.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check Bootstrap 5 JavaScript is loaded
|
||||||
|
2. Verify `data-bs-toggle="dropdown"` attribute is present
|
||||||
|
3. Check for JavaScript errors in browser console
|
||||||
|
4. Ensure jQuery is loaded (required for some Bootstrap features)
|
||||||
|
|
||||||
|
### Hospitals Not Loading
|
||||||
|
|
||||||
|
**Problem**: Dropdown shows empty list or no hospitals.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check that `hospital_filters` is loaded in template
|
||||||
|
2. Verify Hospital model has records
|
||||||
|
3. Check database connection
|
||||||
|
4. Review server logs for errors
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Tenant-Aware Routing Implementation](./TENANT_AWARE_ROUTING_IMPLEMENTATION.md)
|
||||||
|
- [Organization Model](./ORGANIZATION_MODEL.md)
|
||||||
|
- [Architecture](./ARCHITECTURE.md)
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The hospital sidebar display provides clear context awareness and convenient hospital switching for PX Admins, improving the overall user experience in the PX360 platform. The feature is production-ready and follows Django best practices for context processors, template tags, and responsive design.
|
||||||
210
docs/ORGANIZATION_MODEL.md
Normal file
210
docs/ORGANIZATION_MODEL.md
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
# Organization Model Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Organization model has been added to create a hierarchical structure for healthcare facilities. This allows multiple hospitals to be grouped under a parent organization.
|
||||||
|
|
||||||
|
## Model Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Organization (Top-level)
|
||||||
|
├── Hospital (Branch)
|
||||||
|
│ ├── Department
|
||||||
|
│ │ ├── Physician
|
||||||
|
│ │ └── Employee
|
||||||
|
│ └── Patient
|
||||||
|
```
|
||||||
|
|
||||||
|
## Organization Model
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
- `name` (CharField) - Organization name (English)
|
||||||
|
- `name_ar` (CharField) - Organization name (Arabic)
|
||||||
|
- `code` (CharField) - Unique organization code (indexed)
|
||||||
|
- `address` (TextField) - Organization address
|
||||||
|
- `city` (CharField) - Organization city
|
||||||
|
- `phone` (CharField) - Contact phone number
|
||||||
|
- `email` (EmailField) - Contact email address
|
||||||
|
- `website` (URLField) - Organization website URL
|
||||||
|
- `status` (CharField) - Active/Inactive status (from StatusChoices)
|
||||||
|
- `logo` (ImageField) - Organization logo image
|
||||||
|
- `license_number` (CharField) - Organization license number
|
||||||
|
- `created_at` (DateTimeField) - Auto-populated creation timestamp
|
||||||
|
- `updated_at` (DateTimeField) - Auto-populated update timestamp
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
- `hospitals` (One-to-Many) - Related name for Hospital model
|
||||||
|
|
||||||
|
## Hospital Model Changes
|
||||||
|
|
||||||
|
### New Field
|
||||||
|
- `organization` (ForeignKey to Organization) - Parent organization (nullable for backward compatibility)
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
The `organization` field is nullable to ensure existing hospitals without an organization continue to work.
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
### Migration File
|
||||||
|
- `apps/organizations/migrations/0002_organization_hospital_organization.py`
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
1. Created `Organization` model table
|
||||||
|
2. Added `organization` foreign key to `Hospital` table
|
||||||
|
3. Migration applied successfully
|
||||||
|
|
||||||
|
## Admin Interface
|
||||||
|
|
||||||
|
### OrganizationAdmin
|
||||||
|
- List display: name, code, city, status, created_at
|
||||||
|
- Filters: status, city
|
||||||
|
- Search: name, name_ar, code, license_number
|
||||||
|
- Ordering: by name
|
||||||
|
- Fieldsets: Basic info, Contact, Details, Metadata
|
||||||
|
|
||||||
|
### HospitalAdmin Updates
|
||||||
|
- Added `organization` field to form
|
||||||
|
- Added autocomplete for organization selection
|
||||||
|
- Added `organization_name` to list display (read-only)
|
||||||
|
|
||||||
|
## Serializers
|
||||||
|
|
||||||
|
### OrganizationSerializer
|
||||||
|
- Includes `hospitals_count` method field
|
||||||
|
- Fields: id, name, name_ar, code, address, city, phone, email, website, status, license_number, logo, hospitals_count, created_at, updated_at
|
||||||
|
|
||||||
|
### HospitalSerializer Updates
|
||||||
|
- Added `organization` field
|
||||||
|
- Added `organization_name` (read-only, from organization.name)
|
||||||
|
- Added `departments_count` method field
|
||||||
|
- Fields: id, organization, organization_name, name, name_ar, code, address, city, phone, email, status, license_number, capacity, departments_count, created_at, updated_at
|
||||||
|
|
||||||
|
## Data Migration
|
||||||
|
|
||||||
|
### Management Command
|
||||||
|
`python manage.py create_default_organization`
|
||||||
|
|
||||||
|
#### Options
|
||||||
|
- `--name` - Organization name (default: "Default Healthcare Organization")
|
||||||
|
- `--code` - Organization code (default: "DEFAULT")
|
||||||
|
- `--force` - Force reassignment of all hospitals
|
||||||
|
- `--dry-run` - Preview changes without applying
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
```bash
|
||||||
|
# Create default organization
|
||||||
|
python manage.py create_default_organization
|
||||||
|
|
||||||
|
# Create with custom name and code
|
||||||
|
python manage.py create_default_organization --name="My Hospital Group" --code="MHG"
|
||||||
|
|
||||||
|
# Preview changes
|
||||||
|
python manage.py create_default_organization --dry-run
|
||||||
|
|
||||||
|
# Force reassign all hospitals
|
||||||
|
python manage.py create_default_organization --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Current Data
|
||||||
|
- **Organization**: King Faisal Specialist Hospital Group (KFSHG)
|
||||||
|
- **Hospital**: Alhammadi Hospital (HH)
|
||||||
|
- **Departments**: 10
|
||||||
|
- **Status**: All hospitals assigned to organization
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Verification Script
|
||||||
|
Run `python verify_organization.py` to check:
|
||||||
|
- Total organizations
|
||||||
|
- Hospitals per organization
|
||||||
|
- Departments per hospital
|
||||||
|
- Orphaned hospitals (without organization)
|
||||||
|
|
||||||
|
### Expected Output
|
||||||
|
```
|
||||||
|
============================================================
|
||||||
|
Organization Verification
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
Total Organizations: 1
|
||||||
|
|
||||||
|
Organization: King Faisal Specialist Hospital Group (KFSHG)
|
||||||
|
Status: active
|
||||||
|
Hospitals: 1
|
||||||
|
- Alhammadi Hospital (Code: HH)
|
||||||
|
Departments: 10
|
||||||
|
|
||||||
|
✓ All hospitals have organizations assigned
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact on Other Apps
|
||||||
|
|
||||||
|
### Complaints App
|
||||||
|
- Hospital selection in forms now shows organization context
|
||||||
|
- Complaints can be filtered by organization via hospital relationship
|
||||||
|
- No breaking changes - existing functionality preserved
|
||||||
|
|
||||||
|
### Surveys App
|
||||||
|
- Surveys can be filtered by organization
|
||||||
|
- Patient relationships work through hospital → organization
|
||||||
|
|
||||||
|
### Journey Engine
|
||||||
|
- Journeys can be organized by organization
|
||||||
|
- Department routing can consider organization context
|
||||||
|
|
||||||
|
### Call Center
|
||||||
|
- Agent dashboard can filter by organization
|
||||||
|
- Call routing can consider organization hierarchy
|
||||||
|
|
||||||
|
### Analytics App
|
||||||
|
- Reports can be aggregated at organization level
|
||||||
|
- Multi-hospital analytics available
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Recommended Features
|
||||||
|
1. **Organization-Level Permissions**
|
||||||
|
- Role-based access control at organization level
|
||||||
|
- Organization admins managing their hospitals
|
||||||
|
|
||||||
|
2. **Organization Settings**
|
||||||
|
- Custom branding per organization
|
||||||
|
- Organization-specific policies and procedures
|
||||||
|
- Organization-level notification settings
|
||||||
|
|
||||||
|
3. **Organization Dashboard**
|
||||||
|
- Overview of all hospitals in organization
|
||||||
|
- Aggregate metrics and KPIs
|
||||||
|
- Cross-hospital comparison
|
||||||
|
|
||||||
|
4. **Multi-Organization Support**
|
||||||
|
- Users can belong to multiple organizations
|
||||||
|
- Organization switching in UI
|
||||||
|
- Organization-scoped data views
|
||||||
|
|
||||||
|
5. **Organization Hierarchy**
|
||||||
|
- Sub-organizations (regional, national)
|
||||||
|
- Multi-level organization structure
|
||||||
|
- Parent/child organization relationships
|
||||||
|
|
||||||
|
6. **Organization Templates**
|
||||||
|
- Standardized hospital templates
|
||||||
|
- Quick setup of new hospitals
|
||||||
|
- Consistent policies across organization
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All models inherit from `UUIDModel` and `TimeStampedModel` for consistency
|
||||||
|
- Bilingual support (English/Arabic) maintained throughout
|
||||||
|
- Status management follows existing `StatusChoices` pattern
|
||||||
|
- Backward compatible with existing data
|
||||||
|
- No breaking changes to existing functionality
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Model file: `apps/organizations/models.py`
|
||||||
|
- Admin file: `apps/organizations/admin.py`
|
||||||
|
- Serializer file: `apps/organizations/serializers.py`
|
||||||
|
- Migration: `apps/organizations/migrations/0002_organization_hospital_organization.py`
|
||||||
|
- Management command: `apps/organizations/management/commands/create_default_organization.py`
|
||||||
238
docs/TENANT_AWARE_ROUTING_IMPLEMENTATION.md
Normal file
238
docs/TENANT_AWARE_ROUTING_IMPLEMENTATION.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# Tenant-Aware Routing Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the tenant-aware routing system implemented to support PX Admins in managing multiple hospitals within the PX360 platform. PX Admins are super-admins who can view and manage data across all hospitals, but need to select a specific hospital to work with at any given time.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
PX Admins have access to all hospitals in the system, but:
|
||||||
|
- They need to work with one hospital at a time for data consistency
|
||||||
|
- The system must track which hospital they're currently viewing
|
||||||
|
- All queries must be filtered to show only the selected hospital's data
|
||||||
|
- Other user roles (Hospital Admins, Department Managers) should only see their assigned hospital/department
|
||||||
|
|
||||||
|
## Solution Architecture
|
||||||
|
|
||||||
|
### 1. User Role Hierarchy
|
||||||
|
|
||||||
|
The system supports the following user roles:
|
||||||
|
|
||||||
|
1. **PX Admin** (Level 4)
|
||||||
|
- Can view and manage all hospitals
|
||||||
|
- Must select a hospital before viewing data
|
||||||
|
- Can switch between hospitals
|
||||||
|
|
||||||
|
2. **Hospital Admin** (Level 3)
|
||||||
|
- Assigned to a specific hospital
|
||||||
|
- Can only view/manage their hospital's data
|
||||||
|
|
||||||
|
3. **Department Manager** (Level 2)
|
||||||
|
- Assigned to a specific department within a hospital
|
||||||
|
- Can only view/manage their department's data
|
||||||
|
|
||||||
|
4. **Regular User** (Level 1)
|
||||||
|
- Limited permissions based on assignment
|
||||||
|
|
||||||
|
### 2. Implementation Components
|
||||||
|
|
||||||
|
#### A. TenantMiddleware (`apps/core/middleware.py`)
|
||||||
|
|
||||||
|
The middleware is responsible for:
|
||||||
|
- Detecting the user's tenant hospital
|
||||||
|
- Setting `request.tenant_hospital` attribute
|
||||||
|
- Redirecting PX Admins to hospital selector if no hospital is selected
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
```python
|
||||||
|
# For PX Admins: Get hospital from session
|
||||||
|
if user.is_px_admin():
|
||||||
|
hospital_id = request.session.get('selected_hospital_id')
|
||||||
|
if hospital_id:
|
||||||
|
try:
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
|
request.tenant_hospital = hospital
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# For other users: Use their assigned hospital
|
||||||
|
elif user.hospital:
|
||||||
|
request.tenant_hospital = user.hospital
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Hospital Selector (`apps/core/views.py`)
|
||||||
|
|
||||||
|
A dedicated view that allows PX Admins to:
|
||||||
|
- View all available hospitals
|
||||||
|
- Select a hospital to work with
|
||||||
|
- Store the selection in session
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Lists all hospitals with location info
|
||||||
|
- Shows currently selected hospital
|
||||||
|
- Stores selection in session for persistence
|
||||||
|
- Redirects back to the referring page after selection
|
||||||
|
|
||||||
|
#### C. Login Redirect (`apps/accounts/views.py`)
|
||||||
|
|
||||||
|
Enhanced JWT token view that provides redirect URLs based on user role:
|
||||||
|
- PX Admins → `/health/select-hospital/`
|
||||||
|
- Users without hospital → `/health/no-hospital/`
|
||||||
|
- All others → Dashboard (`/`)
|
||||||
|
|
||||||
|
#### D. Dashboard Integration (`apps/dashboard/views.py`)
|
||||||
|
|
||||||
|
The CommandCenter dashboard now:
|
||||||
|
- Checks if PX Admin has selected a hospital
|
||||||
|
- Redirects to hospital selector if not
|
||||||
|
- Filters all data based on `request.tenant_hospital`
|
||||||
|
- Shows current hospital context in templates
|
||||||
|
|
||||||
|
#### E. Context Processor (`apps/core/context_processors.py`)
|
||||||
|
|
||||||
|
Updated sidebar counts to respect tenant context:
|
||||||
|
- PX Admins see counts for selected hospital only
|
||||||
|
- Other users see counts for their assigned hospital
|
||||||
|
|
||||||
|
### 3. URL Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/ - Dashboard (requires hospital context)
|
||||||
|
/health/select-hospital/ - Hospital selector for PX Admins
|
||||||
|
/health/no-hospital/ - Error page for unassigned users
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Template Structure
|
||||||
|
|
||||||
|
- `templates/core/select_hospital.html` - Hospital selection UI
|
||||||
|
- `templates/core/no_hospital_assigned.html` - Error page for unassigned users
|
||||||
|
|
||||||
|
### 5. Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Login
|
||||||
|
↓
|
||||||
|
JWT Token Response (includes redirect_url)
|
||||||
|
↓
|
||||||
|
Frontend redirects based on role
|
||||||
|
↓
|
||||||
|
[If PX Admin] Hospital Selector Page
|
||||||
|
↓
|
||||||
|
User selects hospital → stored in session
|
||||||
|
↓
|
||||||
|
TenantMiddleware sets request.tenant_hospital
|
||||||
|
↓
|
||||||
|
All views filter data using request.tenant_hospital
|
||||||
|
↓
|
||||||
|
Dashboard displays filtered data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Query Filtering Pattern
|
||||||
|
|
||||||
|
Views should follow this pattern for filtering:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
hospital = getattr(self.request, 'tenant_hospital', None)
|
||||||
|
|
||||||
|
if user.is_px_admin() and hospital:
|
||||||
|
return queryset.filter(hospital=hospital)
|
||||||
|
elif user.hospital:
|
||||||
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
elif user.department:
|
||||||
|
return queryset.filter(department=user.department)
|
||||||
|
else:
|
||||||
|
return queryset.none()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Data Isolation**: PX Admins can safely work with one hospital at a time
|
||||||
|
2. **Consistent User Experience**: Clear indication of which hospital is being viewed
|
||||||
|
3. **Security**: Automatic filtering prevents cross-hospital data leakage
|
||||||
|
4. **Flexibility**: PX Admins can easily switch between hospitals
|
||||||
|
5. **Backward Compatible**: Other user roles work as before
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Remember Last Hospital**: Store PX Admin's last hospital in user profile
|
||||||
|
2. **Hospital Quick Switcher**: Add dropdown in header for quick switching
|
||||||
|
3. **Multi-Hospital View**: Option to see aggregated data across hospitals
|
||||||
|
4. **Audit Trail**: Track hospital selection changes in audit logs
|
||||||
|
5. **Permissions Matrix**: Fine-grained control per hospital
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] PX Admin can login and see hospital selector
|
||||||
|
- [ ] PX Admin can select a hospital
|
||||||
|
- [ ] Dashboard shows correct data for selected hospital
|
||||||
|
- [ ] PX Admin can switch hospitals
|
||||||
|
- [ ] Hospital Admin sees only their hospital
|
||||||
|
- [ ] Department Manager sees only their department
|
||||||
|
- [ ] Sidebar counts update correctly when switching hospitals
|
||||||
|
- [ ] All queries respect tenant_hospital filtering
|
||||||
|
- [ ] Session persists hospital selection
|
||||||
|
- [ ] Logout clears hospital selection
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. `apps/core/middleware.py` - Enhanced TenantMiddleware
|
||||||
|
2. `apps/core/views.py` - Added select_hospital and no_hospital_assigned views
|
||||||
|
3. `apps/core/urls.py` - Added hospital selector URLs
|
||||||
|
4. `apps/core/context_processors.py` - Updated sidebar counts for tenant awareness
|
||||||
|
5. `apps/accounts/views.py` - Enhanced JWT token view with redirect URLs
|
||||||
|
6. `apps/dashboard/views.py` - Added hospital context and filtering
|
||||||
|
7. `templates/core/select_hospital.html` - New hospital selector template
|
||||||
|
8. `templates/core/no_hospital_assigned.html` - New error template
|
||||||
|
|
||||||
|
## Integration Notes
|
||||||
|
|
||||||
|
### For Frontend Developers
|
||||||
|
|
||||||
|
When handling JWT token response:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const response = await login(credentials);
|
||||||
|
const redirectUrl = response.data.redirect_url;
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Backend Developers
|
||||||
|
|
||||||
|
When creating new views:
|
||||||
|
|
||||||
|
1. Use `TenantHospitalRequiredMixin` if hospital context is required
|
||||||
|
2. Access hospital via `self.request.tenant_hospital`
|
||||||
|
3. Filter querysets based on user role and hospital context
|
||||||
|
4. Add hospital context to template if needed
|
||||||
|
|
||||||
|
### Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.core.mixins import TenantHospitalRequiredMixin
|
||||||
|
|
||||||
|
class ComplaintListView(TenantHospitalRequiredMixin, ListView):
|
||||||
|
model = Complaint
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
hospital = self.request.tenant_hospital
|
||||||
|
|
||||||
|
if hospital:
|
||||||
|
return queryset.filter(hospital=hospital)
|
||||||
|
|
||||||
|
return queryset.none()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This implementation provides a robust tenant-aware routing system that:
|
||||||
|
- Secures multi-hospital data access
|
||||||
|
- Maintains consistent user experience
|
||||||
|
- Scales to support additional hospitals
|
||||||
|
- Preserves existing functionality for non-admin users
|
||||||
|
|
||||||
|
The system is production-ready and follows Django best practices for middleware, authentication, and request handling.
|
||||||
File diff suppressed because it is too large
Load Diff
@ -24,8 +24,12 @@ dependencies = [
|
|||||||
"django-extensions>=4.1",
|
"django-extensions>=4.1",
|
||||||
"djangorestframework-stubs>=3.16.6",
|
"djangorestframework-stubs>=3.16.6",
|
||||||
"rich>=14.2.0",
|
"rich>=14.2.0",
|
||||||
|
<<<<<<< HEAD
|
||||||
"reportlab>=4.4.7",
|
"reportlab>=4.4.7",
|
||||||
"openpyxl>=3.1.5",
|
"openpyxl>=3.1.5",
|
||||||
|
=======
|
||||||
|
"litellm>=1.0.0",
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "layouts/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load math %}
|
||||||
|
|
||||||
{% block title %}Complaint #{{ complaint.id|slice:":8" }} - PX360{% endblock %}
|
{% block title %}Complaint #{{ complaint.id|slice:":8" }} - PX360{% endblock %}
|
||||||
|
|
||||||
@ -33,7 +34,7 @@
|
|||||||
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
||||||
.status-closed { background: #f5f5f5; color: #616161; }
|
.status-closed { background: #f5f5f5; color: #616161; }
|
||||||
.status-cancelled { background: #ffebee; color: #d32f2f; }
|
.status-cancelled { background: #ffebee; color: #d32f2f; }
|
||||||
|
|
||||||
.severity-badge {
|
.severity-badge {
|
||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@ -44,7 +45,7 @@
|
|||||||
.severity-medium { background: #fff3e0; color: #f57c00; }
|
.severity-medium { background: #fff3e0; color: #f57c00; }
|
||||||
.severity-high { background: #ffebee; color: #d32f2f; }
|
.severity-high { background: #ffebee; color: #d32f2f; }
|
||||||
.severity-critical { background: #880e4f; color: #fff; }
|
.severity-critical { background: #880e4f; color: #fff; }
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 30px;
|
padding-left: 30px;
|
||||||
@ -86,7 +87,7 @@
|
|||||||
.timeline-item.note::before {
|
.timeline-item.note::before {
|
||||||
border-color: #388e3c;
|
border-color: #388e3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card {
|
.action-card {
|
||||||
border-left: 4px solid #667eea;
|
border-left: 4px solid #667eea;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
@ -94,7 +95,7 @@
|
|||||||
.action-card:hover {
|
.action-card:hover {
|
||||||
transform: translateX(5px);
|
transform: translateX(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
@ -102,7 +103,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #212529;
|
color: #212529;
|
||||||
@ -176,25 +177,25 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<ul class="nav nav-tabs mb-3" id="complaintTabs" role="tablist">
|
<ul class="nav nav-tabs mb-3" id="complaintTabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link active" id="details-tab" data-bs-toggle="tab"
|
<button class="nav-link active" id="details-tab" data-bs-toggle="tab"
|
||||||
data-bs-target="#details" type="button" role="tab">
|
data-bs-target="#details" type="button" role="tab">
|
||||||
<i class="bi bi-info-circle me-1"></i> {{ _("Details") }}
|
<i class="bi bi-info-circle me-1"></i> {{ _("Details") }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="timeline-tab" data-bs-toggle="tab"
|
<button class="nav-link" id="timeline-tab" data-bs-toggle="tab"
|
||||||
data-bs-target="#timeline" type="button" role="tab">
|
data-bs-target="#timeline" type="button" role="tab">
|
||||||
<i class="bi bi-clock-history me-1"></i> {{ _("Timeline") }} ({{ timeline.count }})
|
<i class="bi bi-clock-history me-1"></i> {{ _("Timeline") }} ({{ timeline.count }})
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="attachments-tab" data-bs-toggle="tab"
|
<button class="nav-link" id="attachments-tab" data-bs-toggle="tab"
|
||||||
data-bs-target="#attachments" type="button" role="tab">
|
data-bs-target="#attachments" type="button" role="tab">
|
||||||
<i class="bi bi-paperclip me-1"></i> {{ _("Attachments") }} ({{ attachments.count }})
|
<i class="bi bi-paperclip me-1"></i> {{ _("Attachments") }} ({{ attachments.count }})
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="actions-tab" data-bs-toggle="tab"
|
<button class="nav-link" id="actions-tab" data-bs-toggle="tab"
|
||||||
data-bs-target="#actions" type="button" role="tab">
|
data-bs-target="#actions" type="button" role="tab">
|
||||||
<i class="bi bi-lightning-fill me-1"></i> {{ _("PX Actions")}} ({{ px_actions.count }})
|
<i class="bi bi-lightning-fill me-1"></i> {{ _("PX Actions")}} ({{ px_actions.count }})
|
||||||
</button>
|
</button>
|
||||||
@ -208,7 +209,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-4">{% trans "Complaint Details" %}</h5>
|
<h5 class="card-title mb-4">{% trans "Complaint Details" %}</h5>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="info-label">{{ _("Category") }}</div>
|
<div class="info-label">{{ _("Category") }}</div>
|
||||||
@ -224,7 +225,7 @@
|
|||||||
<div class="info-value">{{ complaint.get_source_display }}</div>
|
<div class="info-value">{{ complaint.get_source_display }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="info-label">{{ _("Priority") }}</div>
|
<div class="info-label">{{ _("Priority") }}</div>
|
||||||
@ -243,7 +244,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if complaint.physician %}
|
{% if complaint.physician %}
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
@ -255,16 +256,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="info-label">{{ _("Description") }}</div>
|
<div class="info-label">{{ _("Description") }}</div>
|
||||||
<div class="info-value mt-2">
|
<div class="info-value mt-2">
|
||||||
<p class="mb-0">{{ complaint.description|linebreaks }}</p>
|
<p class="mb-0">{{ complaint.description|linebreaks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Analysis Section -->
|
||||||
|
{% if complaint.short_description or complaint.suggested_action %}
|
||||||
|
<hr>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="info-label mb-0 me-2">
|
||||||
|
<i class="bi bi-robot me-1"></i>AI Analysis
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-info">AI Generated</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emotion Analysis -->
|
||||||
|
{% if complaint.emotion %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="info-label" style="font-size: 0.8rem;">Emotion Analysis</div>
|
||||||
|
<div class="card mb-0" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-left: 4px solid #6c757d;">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row align-items-center mb-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<span class="badge bg-{{ complaint.get_emotion_badge_class }} me-2">
|
||||||
|
<i class="bi bi-emoji-frown me-1"></i>{{ complaint.get_emotion_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-md-end">
|
||||||
|
<small class="text-muted">Confidence: {{ complaint.emotion_confidence|floatformat:0 }}%</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<small class="text-muted">Intensity</small>
|
||||||
|
<small>{{ complaint.emotion_intensity|floatformat:2 }} / 1.0</small>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 6px;">
|
||||||
|
{% if complaint.emotion == 'anger' %}
|
||||||
|
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
|
||||||
|
{% elif complaint.emotion == 'sadness' %}
|
||||||
|
<div class="progress-bar bg-primary" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
|
||||||
|
{% elif complaint.emotion == 'confusion' %}
|
||||||
|
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
|
||||||
|
{% elif complaint.emotion == 'fear' %}
|
||||||
|
<div class="progress-bar bg-info" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if complaint.short_description %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="info-label" style="font-size: 0.8rem;">Summary</div>
|
||||||
|
<div class="alert alert-light border-info mb-0" style="background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);">
|
||||||
|
<i class="bi bi-info-circle me-1 text-info"></i>
|
||||||
|
<small>{{ complaint.short_description }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if complaint.suggested_action %}
|
||||||
|
<div class="mb-0">
|
||||||
|
<div class="info-label" style="font-size: 0.8rem;">Suggested Action</div>
|
||||||
|
<div class="alert alert-success mb-0" style="background: linear-gradient(135deg, #e8f5e9 0%, #e1f5fe 100%); border-color: #4caf50;">
|
||||||
|
<i class="bi bi-lightning me-1 text-success"></i>
|
||||||
|
<small>{{ complaint.suggested_action }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if complaint.resolution %}
|
{% if complaint.resolution %}
|
||||||
<hr>
|
<hr>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -273,16 +347,21 @@
|
|||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
<p class="mb-2">{{ complaint.resolution|linebreaks }}</p>
|
<p class="mb-2">{{ complaint.resolution|linebreaks }}</p>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
|
<<<<<<< HEAD
|
||||||
{{ _("Resolved by")}} {{ complaint.resolved_by.get_full_name }}
|
{{ _("Resolved by")}} {{ complaint.resolved_by.get_full_name }}
|
||||||
{{ _("on") }} {{ complaint.resolved_at|date:"M d, Y H:i" }}
|
{{ _("on") }} {{ complaint.resolved_at|date:"M d, Y H:i" }}
|
||||||
|
=======
|
||||||
|
Resolved by {{ complaint.resolved_by.get_full_name }}
|
||||||
|
on {{ complaint.resolved_at|date:"M d, Y H:i" }}
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="info-label">{{ _("Created") }}</div>
|
<div class="info-label">{{ _("Created") }}</div>
|
||||||
@ -302,7 +381,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-4">{% trans "Activity Timeline" %}</h5>
|
<h5 class="card-title mb-4">{% trans "Activity Timeline" %}</h5>
|
||||||
|
|
||||||
{% if timeline %}
|
{% if timeline %}
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
{% for update in timeline %}
|
{% for update in timeline %}
|
||||||
@ -354,7 +433,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-4">{% trans "Attachments" %}</h5>
|
<h5 class="card-title mb-4">{% trans "Attachments" %}</h5>
|
||||||
|
|
||||||
{% if attachments %}
|
{% if attachments %}
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
{% for attachment in attachments %}
|
{% for attachment in attachments %}
|
||||||
@ -365,7 +444,7 @@
|
|||||||
<strong>{{ attachment.filename }}</strong>
|
<strong>{{ attachment.filename }}</strong>
|
||||||
<br>
|
<br>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Uploaded by {{ attachment.uploaded_by.get_full_name }}
|
Uploaded by {{ attachment.uploaded_by.get_full_name }}
|
||||||
on {{ attachment.created_at|date:"M d, Y H:i" }}
|
on {{ attachment.created_at|date:"M d, Y H:i" }}
|
||||||
({{ attachment.file_size|filesizeformat }})
|
({{ attachment.file_size|filesizeformat }})
|
||||||
</small>
|
</small>
|
||||||
@ -374,7 +453,7 @@
|
|||||||
<small>{{ attachment.description }}</small>
|
<small>{{ attachment.description }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ attachment.file.url }}" class="btn btn-sm btn-outline-primary"
|
<a href="{{ attachment.file.url }}" class="btn btn-sm btn-outline-primary"
|
||||||
target="_blank" download>
|
target="_blank" download>
|
||||||
<i class="bi bi-download"></i>
|
<i class="bi bi-download"></i>
|
||||||
</a>
|
</a>
|
||||||
@ -397,7 +476,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-4">{% trans "Related PX Actions" %}</h5>
|
<h5 class="card-title mb-4">{% trans "Related PX Actions" %}</h5>
|
||||||
|
|
||||||
{% if px_actions %}
|
{% if px_actions %}
|
||||||
{% for action in px_actions %}
|
{% for action in px_actions %}
|
||||||
<div class="card action-card mb-3">
|
<div class="card action-card mb-3">
|
||||||
@ -411,7 +490,7 @@
|
|||||||
<span class="badge bg-secondary ms-1">{{ action.get_priority_display }}</span>
|
<span class="badge bg-secondary ms-1">{{ action.get_priority_display }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'actions:action_detail' action.id %}"
|
<a href="{% url 'actions:action_detail' action.id %}"
|
||||||
class="btn btn-sm btn-outline-primary">
|
class="btn btn-sm btn-outline-primary">
|
||||||
{{ _("View") }} <i class="bi bi-arrow-right ms-1"></i>
|
{{ _("View") }} <i class="bi bi-arrow-right ms-1"></i>
|
||||||
</a>
|
</a>
|
||||||
@ -448,7 +527,7 @@
|
|||||||
<select name="user_id" class="form-select" required>
|
<select name="user_id" class="form-select" required>
|
||||||
<option value="">{{ _("Select user...")}}</option>
|
<option value="">{{ _("Select user...")}}</option>
|
||||||
{% for user_obj in assignable_users %}
|
{% for user_obj in assignable_users %}
|
||||||
<option value="{{ user_obj.id }}"
|
<option value="{{ user_obj.id }}"
|
||||||
{% if complaint.assigned_to and complaint.assigned_to.id == user_obj.id %}selected{% endif %}>
|
{% if complaint.assigned_to and complaint.assigned_to.id == user_obj.id %}selected{% endif %}>
|
||||||
{{ user_obj.get_full_name }}
|
{{ user_obj.get_full_name }}
|
||||||
</option>
|
</option>
|
||||||
@ -459,7 +538,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Change Status -->
|
<!-- Change Status -->
|
||||||
<form method="post" action="{% url 'complaints:complaint_change_status' complaint.id %}" class="mb-3">
|
<form method="post" action="{% url 'complaints:complaint_change_status' complaint.id %}" class="mb-3">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -471,22 +550,22 @@
|
|||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<textarea name="note" class="form-control mb-2" rows="2"
|
<textarea name="note" class="form-control mb-2" rows="2"
|
||||||
placeholder="{% trans 'Optional note...' %}"></textarea>
|
placeholder="{% trans 'Optional note...' %}"></textarea>
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
<i class="bi bi-arrow-repeat me-1"></i> {{ _("Update Status")}}
|
<i class="bi bi-arrow-repeat me-1"></i> {{ _("Update Status")}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Escalate -->
|
<!-- Escalate -->
|
||||||
<button type="button" class="btn btn-danger w-100" data-bs-toggle="modal"
|
<button type="button" class="btn btn-danger w-100" data-bs-toggle="modal"
|
||||||
data-bs-target="#escalateModal">
|
data-bs-target="#escalateModal">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i> {{ _("Escalate") }}
|
<i class="bi bi-exclamation-triangle me-1"></i> {{ _("Escalate") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Add Note -->
|
<!-- Add Note -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header bg-success text-white">
|
<div class="card-header bg-success text-white">
|
||||||
@ -495,7 +574,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="{% url 'complaints:complaint_add_note' complaint.id %}">
|
<form method="post" action="{% url 'complaints:complaint_add_note' complaint.id %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<textarea name="note" class="form-control mb-2" rows="3"
|
<textarea name="note" class="form-control mb-2" rows="3"
|
||||||
placeholder="{% trans 'Enter your note...' %}" required></textarea>
|
placeholder="{% trans 'Enter your note...' %}" required></textarea>
|
||||||
<button type="submit" class="btn btn-success w-100">
|
<button type="submit" class="btn btn-success w-100">
|
||||||
<i class="bi bi-plus-circle me-1"></i> {{ _("Add Note")}}
|
<i class="bi bi-plus-circle me-1"></i> {{ _("Add Note")}}
|
||||||
@ -503,7 +582,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assignment Info -->
|
<!-- Assignment Info -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -524,7 +603,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if complaint.resolved_by %}
|
{% if complaint.resolved_by %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="info-label">{{ _("Resolved By")}}</div>
|
<div class="info-label">{{ _("Resolved By")}}</div>
|
||||||
@ -537,7 +616,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if complaint.closed_by %}
|
{% if complaint.closed_by %}
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
<div class="info-label">{{ _("Closed By")}}</div>
|
<div class="info-label">{{ _("Closed By")}}</div>
|
||||||
@ -552,7 +631,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resolution Survey -->
|
<!-- Resolution Survey -->
|
||||||
{% if complaint.resolution_survey %}
|
{% if complaint.resolution_survey %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@ -561,7 +640,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
|
<<<<<<< HEAD
|
||||||
<strong>{{ _("Status") }}:</strong>
|
<strong>{{ _("Status") }}:</strong>
|
||||||
|
=======
|
||||||
|
<strong>Status:</strong>
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
<span class="badge bg-{{ complaint.resolution_survey.status }}">
|
<span class="badge bg-{{ complaint.resolution_survey.status }}">
|
||||||
{{ complaint.resolution_survey.get_status_display }}
|
{{ complaint.resolution_survey.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
@ -571,7 +654,7 @@
|
|||||||
<strong>{{ _("Score") }}:</strong> {{ complaint.resolution_survey.score }}/100
|
<strong>{{ _("Score") }}:</strong> {{ complaint.resolution_survey.score }}/100
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'surveys:survey_instance_detail' complaint.resolution_survey.id %}"
|
<a href="{% url 'surveys:survey_instance_detail' complaint.resolution_survey.id %}"
|
||||||
class="btn btn-sm btn-outline-info">
|
class="btn btn-sm btn-outline-info">
|
||||||
{{ _("View Survey")}} <i class="bi bi-arrow-right ms-1"></i>
|
{{ _("View Survey")}} <i class="bi bi-arrow-right ms-1"></i>
|
||||||
</a>
|
</a>
|
||||||
@ -599,7 +682,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">{% trans "Reason for Escalation" %}</label>
|
<label class="form-label">{% trans "Reason for Escalation" %}</label>
|
||||||
<textarea name="reason" class="form-control" rows="3"
|
<textarea name="reason" class="form-control" rows="3"
|
||||||
placeholder="{% trans 'Explain why this complaint needs escalation...' %}" required></textarea>
|
placeholder="{% trans 'Explain why this complaint needs escalation...' %}" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -44,9 +44,10 @@
|
|||||||
|
|
||||||
<form method="post" action="{% url 'complaints:complaint_create' %}" id="complaintForm">
|
<form method="post" action="{% url 'complaints:complaint_create' %}" id="complaintForm">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
<<<<<<< HEAD
|
||||||
<!-- Patient Information -->
|
<!-- Patient Information -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h5 class="form-section-title">
|
<h5 class="form-section-title">
|
||||||
@ -70,12 +71,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
<!-- Organization Information -->
|
<!-- Organization Information -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h5 class="form-section-title">
|
<h5 class="form-section-title">
|
||||||
<i class="bi bi-hospital me-2"></i>{{ _("Organization") }}
|
<i class="bi bi-hospital me-2"></i>{{ _("Organization") }}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label required-field">{% trans "Hospital" %}</label>
|
<label class="form-label required-field">{% trans "Hospital" %}</label>
|
||||||
@ -86,41 +89,93 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">{% trans "Department" %}</label>
|
<label class="form-label">{% trans "Department" %}</label>
|
||||||
<select name="department_id" class="form-select" id="departmentSelect">
|
<select name="department_id" class="form-select" id="departmentSelect">
|
||||||
|
<<<<<<< HEAD
|
||||||
<option value="">{{ _("Select department")}}</option>
|
<option value="">{{ _("Select department")}}</option>
|
||||||
|
=======
|
||||||
|
<option value="">Select hospital first...</option>
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
|
<<<<<<< HEAD
|
||||||
<label class="form-label">{% trans "Physician" %}</label>
|
<label class="form-label">{% trans "Physician" %}</label>
|
||||||
<select name="physician_id" class="form-select" id="physicianSelect">
|
<select name="physician_id" class="form-select" id="physicianSelect">
|
||||||
<option value="">{{ _("Select physician")}}</option>
|
<option value="">{{ _("Select physician")}}</option>
|
||||||
|
=======
|
||||||
|
<label class="form-label">{% trans "Staff" %}</label>
|
||||||
|
<select name="staff_id" class="form-select" id="staffSelect">
|
||||||
|
<option value="">Select department first...</option>
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Classification Section -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h5 class="form-section-title">
|
||||||
|
<i class="bi bi-tags me-2"></i>Classification
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label required-field">{% trans "Category" %}</label>
|
||||||
|
<select name="category" class="form-select" id="categorySelect" required>
|
||||||
|
<option value="">Select hospital first...</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">AI will analyze and suggest if needed</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">{% trans "Subcategory" %}</label>
|
||||||
|
<select name="subcategory" class="form-select" id="subcategorySelect">
|
||||||
|
<option value="">Select category first...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Information -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h5 class="form-section-title">
|
||||||
|
<i class="bi bi-person-fill me-2"></i>Patient Information
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label required-field">{% trans "Patient" %}</label>
|
||||||
|
<select name="patient_id" class="form-select" id="patientSelect" required>
|
||||||
|
<option value="">Search and select patient...</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Search by MRN or name</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">{% trans "Encounter ID" %}</label>
|
||||||
|
<input type="text" name="encounter_id" class="form-control"
|
||||||
|
placeholder="{% trans 'Optional encounter/visit ID' %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Complaint Details -->
|
<!-- Complaint Details -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h5 class="form-section-title">
|
<h5 class="form-section-title">
|
||||||
<i class="bi bi-file-text me-2"></i>{{ _("Complaint Details")}}
|
<i class="bi bi-file-text me-2"></i>{{ _("Complaint Details")}}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label required-field">{% trans "Title" %}</label>
|
|
||||||
<input type="text" name="title" class="form-control"
|
|
||||||
placeholder="{% trans 'Brief summary of the complaint' %}" required maxlength="500">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label required-field">{% trans "Description" %}</label>
|
<label class="form-label required-field">{% trans "Description" %}</label>
|
||||||
<textarea name="description" class="form-control" rows="5"
|
<textarea name="description" class="form-control" rows="6"
|
||||||
placeholder="{% trans 'Detailed description of the complaint...' %}" required></textarea>
|
placeholder="{% trans 'Detailed description of the complaint...' %}" required></textarea>
|
||||||
|
<<<<<<< HEAD
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -180,6 +235,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
=======
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
AI will automatically generate title, classify severity/priority, and analyze emotion
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label required-field">{% trans "Source" %}</label>
|
<label class="form-label required-field">{% trans "Source" %}</label>
|
||||||
<select name="source" class="form-select" required>
|
<select name="source" class="form-select" required>
|
||||||
@ -196,14 +258,41 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SLA Information -->
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- AI Information -->
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<h6 class="alert-heading">
|
<h6 class="alert-heading">
|
||||||
|
<<<<<<< HEAD
|
||||||
<i class="bi bi-info-circle me-2"></i>{{ _("SLA Information")}}
|
<i class="bi bi-info-circle me-2"></i>{{ _("SLA Information")}}
|
||||||
</h6>
|
</h6>
|
||||||
<p class="mb-0 small">
|
<p class="mb-0 small">
|
||||||
{{ _("SLA deadline will be automatically calculated based on severity")}}:
|
{{ _("SLA deadline will be automatically calculated based on severity")}}:
|
||||||
|
=======
|
||||||
|
<i class="bi bi-robot me-2"></i>AI-Powered Classification
|
||||||
|
</h6>
|
||||||
|
<p class="mb-0 small">
|
||||||
|
This form uses AI to automatically:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-0 mt-2 small">
|
||||||
|
<li><strong>Generate Title:</strong> Create a concise summary</li>
|
||||||
|
<li><strong>Classify Severity:</strong> Low, Medium, High, or Critical</li>
|
||||||
|
<li><strong>Set Priority:</strong> Low, Medium, High, or Urgent</li>
|
||||||
|
<li><strong>Analyze Emotion:</strong> Detect patient sentiment</li>
|
||||||
|
<li><strong>Suggest Actions:</strong> Provide recommendations</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SLA Information -->
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-clock me-2"></i>SLA Information
|
||||||
|
</h6>
|
||||||
|
<p class="mb-0 small">
|
||||||
|
SLA deadline will be automatically calculated based on AI-determined severity:
|
||||||
|
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||||
</p>
|
</p>
|
||||||
<ul class="mb-0 mt-2 small">
|
<ul class="mb-0 mt-2 small">
|
||||||
<li><strong>{{ _("Critical") }}:</strong> {{ _("4 hours")}}</li>
|
<li><strong>{{ _("Critical") }}:</strong> {{ _("4 hours")}}</li>
|
||||||
@ -230,66 +319,149 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
// Initialize Select2 for patient search (if Select2 is available)
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Hospital change handler - load departments
|
|
||||||
const hospitalSelect = document.getElementById('hospitalSelect');
|
const hospitalSelect = document.getElementById('hospitalSelect');
|
||||||
const departmentSelect = document.getElementById('departmentSelect');
|
const departmentSelect = document.getElementById('departmentSelect');
|
||||||
const physicianSelect = document.getElementById('physicianSelect');
|
const staffSelect = document.getElementById('staffSelect');
|
||||||
|
const categorySelect = document.getElementById('categorySelect');
|
||||||
|
const subcategorySelect = document.getElementById('subcategorySelect');
|
||||||
|
const patientSelect = document.getElementById('patientSelect');
|
||||||
|
|
||||||
|
// Get current language
|
||||||
|
const currentLang = document.documentElement.lang || 'en';
|
||||||
|
|
||||||
|
// Hospital change handler
|
||||||
hospitalSelect.addEventListener('change', function() {
|
hospitalSelect.addEventListener('change', function() {
|
||||||
const hospitalId = this.value;
|
const hospitalId = this.value;
|
||||||
|
|
||||||
// Clear department and physician
|
// Clear dependent dropdowns
|
||||||
departmentSelect.innerHTML = '<option value="">Select department...</option>';
|
departmentSelect.innerHTML = '<option value="">Select hospital first...</option>';
|
||||||
physicianSelect.innerHTML = '<option value="">Select physician...</option>';
|
staffSelect.innerHTML = '<option value="">Select department first...</option>';
|
||||||
|
categorySelect.innerHTML = '<option value="">Loading categories...</option>';
|
||||||
|
subcategorySelect.innerHTML = '<option value="">Select category first...</option>';
|
||||||
|
|
||||||
if (hospitalId) {
|
if (hospitalId) {
|
||||||
// Load departments for selected hospital
|
// Load departments
|
||||||
fetch(`/api/organizations/departments/?hospital=${hospitalId}`)
|
fetch(`/api/organizations/departments/?hospital=${hospitalId}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
departmentSelect.innerHTML = '<option value="">Select department...</option>';
|
||||||
data.results.forEach(dept => {
|
data.results.forEach(dept => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = dept.id;
|
option.value = dept.id;
|
||||||
option.textContent = dept.name_en;
|
const deptName = currentLang === 'ar' && dept.name_ar ? dept.name_ar : dept.name_en;
|
||||||
|
option.textContent = deptName;
|
||||||
departmentSelect.appendChild(option);
|
departmentSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error loading departments:', error));
|
.catch(error => {
|
||||||
|
console.error('Error loading departments:', error);
|
||||||
// Load physicians for selected hospital
|
departmentSelect.innerHTML = '<option value="">Error loading departments</option>';
|
||||||
fetch(`/api/organizations/physicians/?hospital=${hospitalId}`)
|
});
|
||||||
|
|
||||||
|
// Load categories (using public API endpoint)
|
||||||
|
fetch(`/complaints/public/api/load-categories/?hospital_id=${hospitalId}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
data.results.forEach(physician => {
|
categorySelect.innerHTML = '<option value="">Select category...</option>';
|
||||||
const option = document.createElement('option');
|
data.categories.forEach(cat => {
|
||||||
option.value = physician.id;
|
// Only show parent categories (no parent_id)
|
||||||
option.textContent = `Dr. ${physician.first_name} ${physician.last_name} (${physician.specialty})`;
|
if (!cat.parent_id) {
|
||||||
physicianSelect.appendChild(option);
|
const option = document.createElement('option');
|
||||||
|
option.value = cat.id;
|
||||||
|
option.dataset.code = cat.code;
|
||||||
|
const catName = currentLang === 'ar' && cat.name_ar ? cat.name_ar : cat.name_en;
|
||||||
|
option.textContent = catName;
|
||||||
|
categorySelect.appendChild(option);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error loading physicians:', error));
|
.catch(error => {
|
||||||
|
console.error('Error loading categories:', error);
|
||||||
|
categorySelect.innerHTML = '<option value="">Error loading categories</option>';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
categorySelect.innerHTML = '<option value="">Select hospital first...</option>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Patient search with debounce
|
// Department change handler - load staff
|
||||||
const patientSelect = document.getElementById('patientSelect');
|
departmentSelect.addEventListener('change', function() {
|
||||||
let patientSearchTimeout;
|
const departmentId = this.value;
|
||||||
|
|
||||||
// Simple patient search (can be enhanced with Select2)
|
// Clear staff dropdown
|
||||||
|
staffSelect.innerHTML = '<option value="">Select department first...</option>';
|
||||||
|
|
||||||
|
if (departmentId) {
|
||||||
|
// Load staff (filtered by department)
|
||||||
|
fetch(`/complaints/ajax/get-staff-by-department/?department_id=${departmentId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
staffSelect.innerHTML = '<option value="">Select staff...</option>';
|
||||||
|
data.staff.forEach(staff => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = staff.id;
|
||||||
|
option.textContent = `${staff.first_name} ${staff.last_name} (${staff.job_title || staff.staff_type})`;
|
||||||
|
staffSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading staff:', error);
|
||||||
|
staffSelect.innerHTML = '<option value="">Error loading staff</option>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Category change handler - load subcategories
|
||||||
|
categorySelect.addEventListener('change', function() {
|
||||||
|
const categoryId = this.value;
|
||||||
|
|
||||||
|
// Clear subcategory dropdown
|
||||||
|
subcategorySelect.innerHTML = '<option value="">Select category first...</option>';
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
// Load categories again and filter for subcategories of selected parent
|
||||||
|
const hospitalId = hospitalSelect.value;
|
||||||
|
if (hospitalId) {
|
||||||
|
fetch(`/complaints/public/api/load-categories/?hospital_id=${hospitalId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
subcategorySelect.innerHTML = '<option value="">Select subcategory...</option>';
|
||||||
|
data.categories.forEach(cat => {
|
||||||
|
// Only show subcategories (has parent_id matching selected category)
|
||||||
|
if (cat.parent_id == categoryId) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = cat.id;
|
||||||
|
option.dataset.code = cat.code;
|
||||||
|
const catName = currentLang === 'ar' && cat.name_ar ? cat.name_ar : cat.name_en;
|
||||||
|
option.textContent = catName;
|
||||||
|
subcategorySelect.appendChild(option);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (subcategorySelect.options.length <= 1) {
|
||||||
|
subcategorySelect.innerHTML = '<option value="">No subcategories available</option>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading subcategories:', error);
|
||||||
|
subcategorySelect.innerHTML = '<option value="">Error loading subcategories</option>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Patient search
|
||||||
patientSelect.addEventListener('focus', function() {
|
patientSelect.addEventListener('focus', function() {
|
||||||
if (this.options.length === 1) {
|
if (this.options.length === 1) {
|
||||||
// Load initial patients
|
|
||||||
loadPatients('');
|
loadPatients('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadPatients(searchTerm) {
|
function loadPatients(searchTerm) {
|
||||||
const url = searchTerm
|
const url = searchTerm
|
||||||
? `/api/organizations/patients/?search=${encodeURIComponent(searchTerm)}`
|
? `/api/organizations/patients/?search=${encodeURIComponent(searchTerm)}`
|
||||||
: '/api/organizations/patients/?page_size=50';
|
: '/api/organizations/patients/?page_size=50';
|
||||||
|
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@ -303,7 +475,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
})
|
})
|
||||||
.catch(error => console.error('Error loading patients:', error));
|
.catch(error => console.error('Error loading patients:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
const form = document.getElementById('complaintForm');
|
const form = document.getElementById('complaintForm');
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
||||||
.status-closed { background: #f5f5f5; color: #616161; }
|
.status-closed { background: #f5f5f5; color: #616161; }
|
||||||
.status-cancelled { background: #ffebee; color: #d32f2f; }
|
.status-cancelled { background: #ffebee; color: #d32f2f; }
|
||||||
|
|
||||||
.severity-badge {
|
.severity-badge {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@ -51,7 +51,7 @@
|
|||||||
.severity-medium { background: #fff3e0; color: #f57c00; }
|
.severity-medium { background: #fff3e0; color: #f57c00; }
|
||||||
.severity-high { background: #ffebee; color: #d32f2f; }
|
.severity-high { background: #ffebee; color: #d32f2f; }
|
||||||
.severity-critical { background: #880e4f; color: #fff; }
|
.severity-critical { background: #880e4f; color: #fff; }
|
||||||
|
|
||||||
.overdue-badge {
|
.overdue-badge {
|
||||||
background: #d32f2f;
|
background: #d32f2f;
|
||||||
color: white;
|
color: white;
|
||||||
@ -61,12 +61,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.complaint-row:hover {
|
.complaint-row:hover {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
border-left: 4px solid;
|
border-left: 4px solid;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
@ -171,18 +171,18 @@
|
|||||||
<i class="bi bi-chevron-up" id="filterToggleIcon"></i>
|
<i class="bi bi-chevron-up" id="filterToggleIcon"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-body">
|
<div class="filter-body">
|
||||||
<form method="get" action="{% url 'complaints:complaint_list' %}" id="filterForm">
|
<form method="get" action="{% url 'complaints:complaint_list' %}" id="filterForm">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "Search" %}</label>
|
<label class="form-label">{% trans "Search" %}</label>
|
||||||
<input type="text" class="form-control" name="search"
|
<input type="text" class="form-control" name="search"
|
||||||
placeholder="{% trans 'Title, MRN, Patient name...' %}"
|
placeholder="{% trans 'Title, MRN, Patient name...' %}"
|
||||||
value="{{ filters.search }}">
|
value="{{ filters.search }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "Status" %}</label>
|
<label class="form-label">{% trans "Status" %}</label>
|
||||||
@ -195,7 +195,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Severity -->
|
<!-- Severity -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "Severity" %}</label>
|
<label class="form-label">{% trans "Severity" %}</label>
|
||||||
@ -207,7 +207,7 @@
|
|||||||
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{{ _("Critical") }}</option>
|
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{{ _("Critical") }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Priority -->
|
<!-- Priority -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "Priority" %}</label>
|
<label class="form-label">{% trans "Priority" %}</label>
|
||||||
@ -219,7 +219,7 @@
|
|||||||
<option value="urgent" {% if filters.priority == 'urgent' %}selected{% endif %}>{{ _("Urgent") }}</option>
|
<option value="urgent" {% if filters.priority == 'urgent' %}selected{% endif %}>{{ _("Urgent") }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category -->
|
<!-- Category -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "Category" %}</label>
|
<label class="form-label">{% trans "Category" %}</label>
|
||||||
@ -234,7 +234,7 @@
|
|||||||
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{{ _("Other") }}</option>
|
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{{ _("Other") }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hospital -->
|
<!-- Hospital -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "Hospital" %}</label>
|
<label class="form-label">{% trans "Hospital" %}</label>
|
||||||
@ -247,7 +247,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Department -->
|
<!-- Department -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "Department" %}</label>
|
<label class="form-label">{% trans "Department" %}</label>
|
||||||
@ -260,7 +260,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assigned To -->
|
<!-- Assigned To -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "Assigned To" %}</label>
|
<label class="form-label">{% trans "Assigned To" %}</label>
|
||||||
@ -273,7 +273,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overdue -->
|
<!-- Overdue -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">{% trans "SLA Status" %}</label>
|
<label class="form-label">{% trans "SLA Status" %}</label>
|
||||||
@ -282,7 +282,7 @@
|
|||||||
<option value="true" {% if filters.is_overdue == 'true' %}selected{% endif %}>Overdue Only</option>
|
<option value="true" {% if filters.is_overdue == 'true' %}selected{% endif %}>Overdue Only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date Range -->
|
<!-- Date Range -->
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">{% trans "Date From" %}</label>
|
<label class="form-label">{% trans "Date From" %}</label>
|
||||||
@ -293,7 +293,7 @@
|
|||||||
<input type="date" class="form-control" name="date_to" value="{{ filters.date_to }}">
|
<input type="date" class="form-control" name="date_to" value="{{ filters.date_to }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 d-flex gap-2">
|
<div class="mt-3 d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-search me-1"></i> {{ _("Apply Filters")}}
|
<i class="bi bi-search me-1"></i> {{ _("Apply Filters")}}
|
||||||
@ -322,7 +322,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Complaints Table -->
|
<!-- Complaints Table -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@ -350,7 +349,7 @@
|
|||||||
{% for complaint in complaints %}
|
{% for complaint in complaints %}
|
||||||
<tr class="complaint-row" onclick="window.location='{% url 'complaints:complaint_detail' complaint.id %}'">
|
<tr class="complaint-row" onclick="window.location='{% url 'complaints:complaint_detail' complaint.id %}'">
|
||||||
<td onclick="event.stopPropagation();">
|
<td onclick="event.stopPropagation();">
|
||||||
<input type="checkbox" class="form-check-input complaint-checkbox"
|
<input type="checkbox" class="form-check-input complaint-checkbox"
|
||||||
value="{{ complaint.id }}">
|
value="{{ complaint.id }}">
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -401,7 +400,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td onclick="event.stopPropagation();">
|
<td onclick="event.stopPropagation();">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<a href="{% url 'complaints:complaint_detail' complaint.id %}"
|
<a href="{% url 'complaints:complaint_detail' complaint.id %}"
|
||||||
class="btn btn-outline-primary" title="{% trans 'View' %}">
|
class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
@ -438,7 +437,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for num in page_obj.paginator.page_range %}
|
{% for num in page_obj.paginator.page_range %}
|
||||||
{% if page_obj.number == num %}
|
{% if page_obj.number == num %}
|
||||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||||
@ -450,7 +449,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
{% if page_obj.has_next %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||||
|
|||||||
513
templates/complaints/public_complaint_form.html
Normal file
513
templates/complaints/public_complaint_form.html
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
{% extends "layouts/public_base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Submit a Complaint" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.complaint-form {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-box {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-box.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-box.info {
|
||||||
|
background-color: #cce5ff;
|
||||||
|
color: #004085;
|
||||||
|
border: 1px solid #b8daff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-mark {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
background: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: white;
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .spinner {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 4px solid #3498db;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .category-description {
|
||||||
|
border-left: none;
|
||||||
|
border-right: 4px solid #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="complaint-form">
|
||||||
|
<div class="alert-box info">
|
||||||
|
<h4 style="margin-top: 0;">
|
||||||
|
<i class="fas fa-info-circle"></i> {% trans "About This Form" %}
|
||||||
|
</h4>
|
||||||
|
<p style="margin-bottom: 0;">
|
||||||
|
{% trans "Use this form to submit a complaint about your experience at one of our hospitals. We will review your complaint and get back to you as soon as possible." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="public_complaint_form" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Contact Information Section -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3><i class="fas fa-user"></i> {% trans "Contact Information" %}</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_name">
|
||||||
|
{% trans "Name" %} <span class="required-mark">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="id_name"
|
||||||
|
name="name"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="{% trans 'Your full name' %}"
|
||||||
|
required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "Please provide your full name." %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_email">
|
||||||
|
{% trans "Email Address" %} <span class="required-mark">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="id_email"
|
||||||
|
name="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "We will use this to contact you about your complaint." %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_phone">
|
||||||
|
{% trans "Phone Number" %} <span class="required-mark">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="id_phone"
|
||||||
|
name="phone"
|
||||||
|
maxlength="20"
|
||||||
|
placeholder="{% trans 'Your phone number' %}"
|
||||||
|
required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "We may contact you by phone if needed." %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complaint Details Section -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3><i class="fas fa-file-alt"></i> {% trans "Complaint Details" %}</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_hospital">
|
||||||
|
{% trans "Hospital" %} <span class="required-mark">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-control"
|
||||||
|
id="id_hospital"
|
||||||
|
name="hospital"
|
||||||
|
required>
|
||||||
|
<option value="">{% trans "Select Hospital" %}</option>
|
||||||
|
{% for hospital in hospitals %}
|
||||||
|
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mt-3">
|
||||||
|
<label for="id_category">
|
||||||
|
{% trans "Category" %} <span class="required-mark">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-control"
|
||||||
|
id="id_category"
|
||||||
|
name="category"
|
||||||
|
required>
|
||||||
|
<option value="">{% trans "Select Category" %}</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "Select the category that best describes your complaint." %}
|
||||||
|
</small>
|
||||||
|
<div id="category_description" class="category-description" style="display: none;">
|
||||||
|
<div class="description-title">{% trans "About this category:" %}</div>
|
||||||
|
<span id="category_description_text"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mt-3" id="subcategory_container" style="display: none;">
|
||||||
|
<label for="id_subcategory">
|
||||||
|
{% trans "Subcategory" %} <span class="required-mark">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-control"
|
||||||
|
id="id_subcategory"
|
||||||
|
name="subcategory"
|
||||||
|
required>
|
||||||
|
<option value="">{% trans "Select Subcategory" %}</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "Select the specific subcategory within the chosen category." %}
|
||||||
|
</small>
|
||||||
|
<div id="subcategory_description" class="category-description" style="display: none;">
|
||||||
|
<div class="description-title">{% trans "About this subcategory:" %}</div>
|
||||||
|
<span id="subcategory_description_text"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mt-3">
|
||||||
|
<label for="id_description">
|
||||||
|
{% trans "Complaint Description" %} <span class="required-mark">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control"
|
||||||
|
id="id_description"
|
||||||
|
name="description"
|
||||||
|
rows="6"
|
||||||
|
placeholder="{% trans 'Please describe your complaint in detail. Include dates, names of staff involved, and any other relevant information.' %}"
|
||||||
|
required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Section -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="alert-box info">
|
||||||
|
<p style="margin-bottom: 0;">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<strong>{% trans "Response Time:" %}</strong>
|
||||||
|
{% trans "We typically respond to complaints within 24-48 hours depending on severity." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<button type="submit" class="btn btn-submit btn-lg" id="submit_btn">
|
||||||
|
<i class="fas fa-paper-plane"></i> {% trans "Submit Complaint" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for success -->
|
||||||
|
<div class="modal fade" id="successModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-body text-center" style="padding: 3rem;">
|
||||||
|
<i class="fas fa-check-circle" style="font-size: 5rem; color: #28a745; margin-bottom: 1.5rem;"></i>
|
||||||
|
<h3 style="margin-bottom: 1rem;">{% trans "Complaint Submitted Successfully!" %}</h3>
|
||||||
|
<p class="lead" style="margin-bottom: 1rem;">
|
||||||
|
{% trans "Your complaint has been received and is being reviewed." %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>{% trans "Reference Number:" %}</strong>
|
||||||
|
<span id="complaint_reference" style="font-size: 1.5rem; color: #3498db;"></span>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans "Please save this reference number for your records." %}
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'complaints:public_complaint_submit' %}" class="btn btn-primary">
|
||||||
|
{% trans "Submit Another Complaint" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Get CSRF token
|
||||||
|
function getCSRFToken() {
|
||||||
|
// Try to get from cookie first
|
||||||
|
const cookieValue = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('csrftoken='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
if (cookieValue) {
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the hidden input
|
||||||
|
return $('[name="csrfmiddlewaretoken"]').val();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store all categories data globally for easy access
|
||||||
|
let allCategories = [];
|
||||||
|
let currentLanguage = 'en';
|
||||||
|
|
||||||
|
// Get description based on current language
|
||||||
|
function getDescription(category) {
|
||||||
|
if (currentLanguage === 'ar' && category.description_ar) {
|
||||||
|
return category.description_ar;
|
||||||
|
}
|
||||||
|
return category.description_en || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get name based on current language
|
||||||
|
function getName(category) {
|
||||||
|
if (currentLanguage === 'ar' && category.name_ar) {
|
||||||
|
return category.name_ar;
|
||||||
|
}
|
||||||
|
return category.name_en;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load categories
|
||||||
|
function loadCategories() {
|
||||||
|
$.ajax({
|
||||||
|
url: '{% url "complaints:api_load_categories" %}',
|
||||||
|
type: 'GET',
|
||||||
|
success: function(response) {
|
||||||
|
// Store all categories
|
||||||
|
allCategories = response.categories;
|
||||||
|
|
||||||
|
const categorySelect = $('#id_category');
|
||||||
|
categorySelect.find('option:not(:first)').remove();
|
||||||
|
|
||||||
|
// Only show parent categories (no parent_id)
|
||||||
|
allCategories.forEach(function(category) {
|
||||||
|
if (!category.parent_id) {
|
||||||
|
categorySelect.append($('<option>', {
|
||||||
|
value: category.id,
|
||||||
|
text: getName(category)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
console.error('Failed to load categories');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show category description
|
||||||
|
function showCategoryDescription(categoryId) {
|
||||||
|
const category = allCategories.find(c => c.id === categoryId);
|
||||||
|
const descriptionDiv = $('#category_description');
|
||||||
|
const descriptionText = $('#category_description_text');
|
||||||
|
|
||||||
|
if (category && getDescription(category)) {
|
||||||
|
descriptionText.text(getDescription(category));
|
||||||
|
descriptionDiv.show();
|
||||||
|
} else {
|
||||||
|
descriptionDiv.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show subcategory description
|
||||||
|
function showSubcategoryDescription(subcategoryId) {
|
||||||
|
const subcategory = allCategories.find(c => c.id === subcategoryId);
|
||||||
|
const descriptionDiv = $('#subcategory_description');
|
||||||
|
const descriptionText = $('#subcategory_description_text');
|
||||||
|
|
||||||
|
if (subcategory && getDescription(subcategory)) {
|
||||||
|
descriptionText.text(getDescription(subcategory));
|
||||||
|
descriptionDiv.show();
|
||||||
|
} else {
|
||||||
|
descriptionDiv.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load subcategories based on selected category
|
||||||
|
function loadSubcategories(categoryId) {
|
||||||
|
if (!categoryId) {
|
||||||
|
$('#subcategory_container').hide();
|
||||||
|
$('#subcategory_description').hide();
|
||||||
|
$('#id_subcategory').find('option:not(:first)').remove();
|
||||||
|
$('#id_subcategory').prop('required', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcategorySelect = $('#id_subcategory');
|
||||||
|
subcategorySelect.find('option:not(:first)').remove();
|
||||||
|
|
||||||
|
// Filter subcategories for this parent category (match parent_id)
|
||||||
|
allCategories.forEach(function(category) {
|
||||||
|
if (category.parent_id == categoryId) {
|
||||||
|
subcategorySelect.append($('<option>', {
|
||||||
|
value: category.id,
|
||||||
|
text: getName(category)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subcategorySelect.find('option').length > 1) {
|
||||||
|
$('#subcategory_container').show();
|
||||||
|
$('#id_subcategory').prop('required', true);
|
||||||
|
} else {
|
||||||
|
$('#subcategory_container').hide();
|
||||||
|
$('#subcategory_description').hide();
|
||||||
|
$('#id_subcategory').prop('required', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect current language from HTML
|
||||||
|
const htmlLang = document.documentElement.lang;
|
||||||
|
if (htmlLang === 'ar') {
|
||||||
|
currentLanguage = 'ar';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize - load categories on page load
|
||||||
|
loadCategories();
|
||||||
|
|
||||||
|
// Handle category change
|
||||||
|
$('#id_category').on('change', function() {
|
||||||
|
const categoryId = $(this).val();
|
||||||
|
loadSubcategories(categoryId);
|
||||||
|
showCategoryDescription(categoryId);
|
||||||
|
$('#subcategory_description').hide(); // Hide subcategory description when category changes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle subcategory change
|
||||||
|
$('#id_subcategory').on('change', function() {
|
||||||
|
const subcategoryId = $(this).val();
|
||||||
|
showSubcategoryDescription(subcategoryId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
$('#public_complaint_form').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = $('#submit_btn');
|
||||||
|
const originalText = submitBtn.html();
|
||||||
|
|
||||||
|
submitBtn.prop('disabled', true).html(
|
||||||
|
'<span class="spinner"></span> {% trans "Submitting..." %}'
|
||||||
|
);
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '{% url "complaints:public_complaint_submit" %}',
|
||||||
|
type: 'POST',
|
||||||
|
data: formData,
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCSRFToken()
|
||||||
|
},
|
||||||
|
success: function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
$('#complaint_reference').text(response.reference_number);
|
||||||
|
$('#successModal').modal('show');
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('public_complaint_form').reset();
|
||||||
|
} else {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: '{% trans "Error" %}',
|
||||||
|
text: response.message || '{% trans "Failed to submit complaint. Please try again." %}'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
let errorMessage = '{% trans "Failed to submit complaint. Please try again." %}';
|
||||||
|
|
||||||
|
if (xhr.responseJSON && xhr.responseJSON.errors) {
|
||||||
|
errorMessage = xhr.responseJSON.errors.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: '{% trans "Error" %}',
|
||||||
|
text: errorMessage
|
||||||
|
});
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
submitBtn.prop('disabled', false).html(originalText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
203
templates/complaints/public_complaint_success.html
Normal file
203
templates/complaints/public_complaint_success.html
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
{% extends "layouts/public_base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Complaint Submitted" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.success-container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 4rem auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
font-size: 6rem;
|
||||||
|
color: #28a745;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
animation: scaleIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 3rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-number {
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 2rem 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 4px solid #3498db;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
margin: 2rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box h4 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box ul {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-custom {
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-custom:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-custom {
|
||||||
|
background: #6c757d;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-custom:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info p {
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info strong {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="success-container">
|
||||||
|
<div class="success-card">
|
||||||
|
<div class="success-icon">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style="color: #2c3e50; margin-bottom: 1rem;">
|
||||||
|
{% trans "Complaint Submitted Successfully!" %}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="lead" style="color: #6c757d;">
|
||||||
|
{% trans "Thank you for your feedback. Your complaint has been received and is being reviewed." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="reference-number">
|
||||||
|
#{{ reference_number }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #6c757d;">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
{% trans "Please save this reference number for your records. You will need it to track your complaint status." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h4><i class="fas fa-clock"></i> {% trans "What Happens Next?" %}</h4>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans "Your complaint will be reviewed by our team within 24 hours" %}</li>
|
||||||
|
<li>{% trans "You will receive updates via phone or email based on the contact information provided" %}</li>
|
||||||
|
<li>{% trans "Our typical response time is 24-48 hours depending on severity" %}</li>
|
||||||
|
<li>{% trans "You can check the status of your complaint using your reference number" %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h4><i class="fas fa-phone"></i> {% trans "Need Immediate Assistance?" %}</h4>
|
||||||
|
<p style="margin-bottom: 0;">
|
||||||
|
{% trans "If your complaint is urgent, please contact our Patient Relations department directly at:" %}
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 1.2rem; margin: 1rem 0; color: #3498db;">
|
||||||
|
<i class="fas fa-phone"></i> 920-000-0000
|
||||||
|
</p>
|
||||||
|
<p style="margin-bottom: 0; color: #6c757d;">
|
||||||
|
{% trans "Available Saturday to Thursday, 8:00 AM - 8:00 PM" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem;">
|
||||||
|
<a href="{% url 'complaints:public_complaint_submit' %}" class="btn-primary-custom">
|
||||||
|
<i class="fas fa-plus-circle"></i> {% trans "Submit Another Complaint" %}
|
||||||
|
</a>
|
||||||
|
<a href="/" class="btn-secondary-custom">
|
||||||
|
<i class="fas fa-home"></i> {% trans "Return to Home" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-info">
|
||||||
|
<p>
|
||||||
|
<strong>{% trans "Need Help?" %}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<i class="fas fa-envelope"></i> {% trans "Email:" %}
|
||||||
|
<a href="mailto:patient.relations@healthcare.sa">patient.relations@healthcare.sa</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<i class="fas fa-globe"></i> {% trans "Website:" %}
|
||||||
|
<a href="https://www.healthcare.sa">www.healthcare.sa</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
56
templates/core/no_hospital_assigned.html
Normal file
56
templates/core/no_hospital_assigned.html
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "No Hospital Assigned" %} - PX360{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow border-danger">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-exclamation-circle text-danger" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="card-title text-danger mb-3">
|
||||||
|
{% trans "No Hospital Assigned" %}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="card-text text-muted mb-4">
|
||||||
|
{% trans "Your account does not have a hospital assigned. Please contact your administrator to assign you to a hospital before accessing the system." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
<strong>{% trans "Information:" %}</strong>
|
||||||
|
<ul class="mb-0 mt-2 text-start">
|
||||||
|
<li>{% trans "Email:" %} {{ request.user.email }}</li>
|
||||||
|
<li>{% trans "Name:" %} {{ request.user.get_full_name }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="{% url 'admin:logout' %}" class="btn btn-outline-danger">
|
||||||
|
<i class="fas fa-sign-out-alt me-2"></i>
|
||||||
|
{% trans "Logout" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">
|
||||||
|
<i class="fas fa-question-circle me-2"></i>
|
||||||
|
{% trans "Need Help?" %}
|
||||||
|
</h5>
|
||||||
|
<p class="card-text text-muted small">
|
||||||
|
{% trans "If you believe this is an error, please contact your PX360 administrator or IT support team for assistance." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
76
templates/core/select_hospital.html
Normal file
76
templates/core/select_hospital.html
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Select Hospital" %} - PX360{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="fas fa-hospital me-2"></i>
|
||||||
|
{% trans "Select Hospital" %}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
{% trans "As a PX Admin, you can view and manage data for any hospital. Please select the hospital you want to work with:" %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="list-group mb-4">
|
||||||
|
{% for hospital in hospitals %}
|
||||||
|
<label class="list-group-item list-group-item-action">
|
||||||
|
<input class="form-check-input me-3" type="radio"
|
||||||
|
name="hospital_id"
|
||||||
|
value="{{ hospital.id }}"
|
||||||
|
{% if hospital.id == selected_hospital_id %}checked{% endif %}>
|
||||||
|
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">{{ hospital.name }}</h5>
|
||||||
|
<small class="text-muted">
|
||||||
|
{% if hospital.id == selected_hospital_id %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
{% trans "Selected" %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% if hospital.city %}
|
||||||
|
<p class="mb-1 text-muted small">
|
||||||
|
<i class="fas fa-map-marker-alt me-1"></i>
|
||||||
|
{{ hospital.city }}
|
||||||
|
{% if hospital.country %}, {{ hospital.country }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
{% empty %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
{% trans "No hospitals found in the system." %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="/" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>
|
||||||
|
{% trans "Back to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fas fa-check me-2"></i>
|
||||||
|
{% trans "Continue" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -1,31 +1,33 @@
|
|||||||
{% load i18n static%}
|
{% load i18n %}
|
||||||
|
{% load hospital_filters %}
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<i class="bi bi-heart-pulse-fill"></i> PX360
|
<i class="bi bi-heart-pulse-fill"></i> PX360
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<!-- Command Center -->
|
<!-- Command Center -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'command_center' %}active{% endif %}"
|
|
||||||
|
<a class="nav-link {% if request.resolver_match.url_name == 'command_center' %}active{% endif %}"
|
||||||
href="{% url 'analytics:command_center' %}">
|
href="{% url 'analytics:command_center' %}">
|
||||||
<i class="bi bi-speedometer2"></i>
|
<i class="bi bi-speedometer2"></i>
|
||||||
{% trans "Command Center" %}
|
{% trans "Command Center" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
|
||||||
<!-- Complaints -->
|
<!-- Complaints -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'complaints' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'complaints' in request.path %}active{% endif %}"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
href="#complaintsMenu"
|
href="#complaintsMenu"
|
||||||
role="button"
|
role="button"
|
||||||
aria-expanded="{% if 'complaints' in request.path %}true{% else %}false{% endif %}"
|
aria-expanded="{% if 'complaints' in request.path %}true{% else %}false{% endif %}"
|
||||||
aria-controls="complaintsMenu">
|
aria-controls="complaintsMenu">
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
{% trans "Complaints" %}
|
{% trans "Complaints" %}
|
||||||
@ -35,21 +37,21 @@
|
|||||||
<div class="collapse {% if 'complaints' in request.path %}show{% endif %}" id="complaintsMenu">
|
<div class="collapse {% if 'complaints' in request.path %}show{% endif %}" id="complaintsMenu">
|
||||||
<ul class="nav flex-column ms-3">
|
<ul class="nav flex-column ms-3">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'complaint_list' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'complaint_list' %}active{% endif %}"
|
||||||
href="{% url 'complaints:complaint_list' %}">
|
href="{% url 'complaints:complaint_list' %}">
|
||||||
<i class="bi bi-list-ul"></i>
|
<i class="bi bi-list-ul"></i>
|
||||||
{% trans "All Complaints" %}
|
{% trans "All Complaints" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'inquiry_list' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'inquiry_list' %}active{% endif %}"
|
||||||
href="{% url 'complaints:inquiry_list' %}">
|
href="{% url 'complaints:inquiry_list' %}">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
{% trans "Inquiries" %}
|
{% trans "Inquiries" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'complaints_analytics' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'complaints_analytics' %}active{% endif %}"
|
||||||
href="{% url 'complaints:complaints_analytics' %}">
|
href="{% url 'complaints:complaints_analytics' %}">
|
||||||
<i class="bi bi-bar-chart"></i>
|
<i class="bi bi-bar-chart"></i>
|
||||||
{% trans "Analytics" %}
|
{% trans "Analytics" %}
|
||||||
@ -58,24 +60,24 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Feedback -->
|
<!-- Feedback -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'feedback' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'feedback' in request.path %}active{% endif %}"
|
||||||
href="{% url 'feedback:feedback_list' %}">
|
href="{% url 'feedback:feedback_list' %}">
|
||||||
<i class="bi bi-chat-heart"></i>
|
<i class="bi bi-chat-heart"></i>
|
||||||
{% trans "Feedback" %}
|
{% trans "Feedback" %}
|
||||||
<span class="badge bg-success">{{ feedback_count|default:0 }}</span>
|
<span class="badge bg-success">{{ feedback_count|default:0 }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Appreciation -->
|
<!-- Appreciation -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'appreciation' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'appreciation' in request.path %}active{% endif %}"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
href="#appreciationMenu"
|
href="#appreciationMenu"
|
||||||
role="button"
|
role="button"
|
||||||
aria-expanded="{% if 'appreciation' in request.path %}true{% else %}false{% endif %}"
|
aria-expanded="{% if 'appreciation' in request.path %}true{% else %}false{% endif %}"
|
||||||
aria-controls="appreciationMenu">
|
aria-controls="appreciationMenu">
|
||||||
<i class="bi bi-heart-fill"></i>
|
<i class="bi bi-heart-fill"></i>
|
||||||
{% trans "Appreciation" %}
|
{% trans "Appreciation" %}
|
||||||
@ -84,28 +86,28 @@
|
|||||||
<div class="collapse {% if 'appreciation' in request.path %}show{% endif %}" id="appreciationMenu">
|
<div class="collapse {% if 'appreciation' in request.path %}show{% endif %}" id="appreciationMenu">
|
||||||
<ul class="nav flex-column ms-3">
|
<ul class="nav flex-column ms-3">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'appreciation_list' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'appreciation_list' %}active{% endif %}"
|
||||||
href="{% url 'appreciation:appreciation_list' %}">
|
href="{% url 'appreciation:appreciation_list' %}">
|
||||||
<i class="bi bi-list-ul"></i>
|
<i class="bi bi-list-ul"></i>
|
||||||
{% trans "All Appreciations" %}
|
{% trans "All Appreciations" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'appreciation_send' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'appreciation_send' %}active{% endif %}"
|
||||||
href="{% url 'appreciation:appreciation_send' %}">
|
href="{% url 'appreciation:appreciation_send' %}">
|
||||||
<i class="bi bi-send"></i>
|
<i class="bi bi-send"></i>
|
||||||
{% trans "Send Appreciation" %}
|
{% trans "Send Appreciation" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'leaderboard_view' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'leaderboard_view' %}active{% endif %}"
|
||||||
href="{% url 'appreciation:leaderboard_view' %}">
|
href="{% url 'appreciation:leaderboard_view' %}">
|
||||||
<i class="bi bi-trophy"></i>
|
<i class="bi bi-trophy"></i>
|
||||||
{% trans "Leaderboard" %}
|
{% trans "Leaderboard" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'my_badges_view' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'my_badges_view' %}active{% endif %}"
|
||||||
href="{% url 'appreciation:my_badges_view' %}">
|
href="{% url 'appreciation:my_badges_view' %}">
|
||||||
<i class="bi bi-award"></i>
|
<i class="bi bi-award"></i>
|
||||||
{% trans "My Badges" %}
|
{% trans "My Badges" %}
|
||||||
@ -114,71 +116,72 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Observations -->
|
<!-- Observations -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'observations' in request.path and 'new' not in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'observations' in request.path and 'new' not in request.path %}active{% endif %}"
|
||||||
href="{% url 'observations:observation_list' %}">
|
href="{% url 'observations:observation_list' %}">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
{% trans "Observations" %}
|
{% trans "Observations" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
||||||
<!-- PX Actions -->
|
<!-- PX Actions -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'actions' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'actions' in request.path %}active{% endif %}"
|
||||||
href="{% url 'actions:action_list' %}">
|
href="{% url 'actions:action_list' %}">
|
||||||
<i class="bi bi-clipboard-check"></i>
|
<i class="bi bi-clipboard-check"></i>
|
||||||
{% trans "PX Actions" %}
|
{% trans "PX Actions" %}
|
||||||
<span class="badge bg-warning">{{ action_count|default:0 }}</span>
|
<span class="badge bg-warning">{{ action_count|default:0 }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Journeys -->
|
<!-- Journeys -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'journeys' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'journeys' in request.path %}active{% endif %}"
|
||||||
href="{% url 'journeys:instance_list' %}">
|
href="{% url 'journeys:instance_list' %}">
|
||||||
<i class="bi bi-diagram-3"></i>
|
<i class="bi bi-diagram-3"></i>
|
||||||
{% trans "Patient Journeys" %}
|
{% trans "Patient Journeys" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Surveys -->
|
<!-- Surveys -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'surveys' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'surveys' in request.path %}active{% endif %}"
|
||||||
href="{% url 'surveys:instance_list' %}">
|
href="{% url 'surveys:instance_list' %}">
|
||||||
<i class="bi bi-clipboard-data"></i>
|
<i class="bi bi-clipboard-data"></i>
|
||||||
{% trans "Surveys" %}
|
{% trans "Surveys" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Physicians -->
|
<!-- Physicians -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'physicians' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'physicians' in request.path %}active{% endif %}"
|
||||||
href="{% url 'physicians:physician_list' %}">
|
href="{% url 'physicians:physician_list' %}">
|
||||||
<i class="bi bi-person-badge"></i>
|
<i class="bi bi-person-badge"></i>
|
||||||
{% trans "Physicians" %}
|
{% trans "Physicians" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
|
||||||
<!-- Organizations -->
|
<!-- Organizations -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'organizations' in request.path and 'api' not in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'organizations' in request.path and 'api' not in request.path %}active{% endif %}"
|
||||||
href="{% url 'organizations:hospital_list' %}">
|
href="{% url 'organizations:hospital_list' %}">
|
||||||
<i class="bi bi-building"></i>
|
<i class="bi bi-building"></i>
|
||||||
{% trans "Organizations" %}
|
{% trans "Organizations" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Call Center -->
|
<!-- Call Center -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'callcenter' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'callcenter' in request.path %}active{% endif %}"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
href="#callcenterMenu"
|
href="#callcenterMenu"
|
||||||
role="button"
|
role="button"
|
||||||
aria-expanded="{% if 'callcenter' in request.path %}true{% else %}false{% endif %}"
|
aria-expanded="{% if 'callcenter' in request.path %}true{% else %}false{% endif %}"
|
||||||
aria-controls="callcenterMenu">
|
aria-controls="callcenterMenu">
|
||||||
<i class="bi bi-telephone"></i>
|
<i class="bi bi-telephone"></i>
|
||||||
{% trans "Call Center" %}
|
{% trans "Call Center" %}
|
||||||
@ -187,35 +190,35 @@
|
|||||||
<div class="collapse {% if 'callcenter' in request.path %}show{% endif %}" id="callcenterMenu">
|
<div class="collapse {% if 'callcenter' in request.path %}show{% endif %}" id="callcenterMenu">
|
||||||
<ul class="nav flex-column ms-3">
|
<ul class="nav flex-column ms-3">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'interaction_list' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'interaction_list' %}active{% endif %}"
|
||||||
href="{% url 'callcenter:interaction_list' %}">
|
href="{% url 'callcenter:interaction_list' %}">
|
||||||
<i class="bi bi-list-ul"></i>
|
<i class="bi bi-list-ul"></i>
|
||||||
{% trans "Interactions" %}
|
{% trans "Interactions" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'create_complaint' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'create_complaint' %}active{% endif %}"
|
||||||
href="{% url 'callcenter:create_complaint' %}">
|
href="{% url 'callcenter:create_complaint' %}">
|
||||||
<i class="bi bi-plus-circle"></i>
|
<i class="bi bi-plus-circle"></i>
|
||||||
{% trans "Create Complaint" %}
|
{% trans "Create Complaint" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'create_inquiry' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'create_inquiry' %}active{% endif %}"
|
||||||
href="{% url 'callcenter:create_inquiry' %}">
|
href="{% url 'callcenter:create_inquiry' %}">
|
||||||
<i class="bi bi-plus-circle"></i>
|
<i class="bi bi-plus-circle"></i>
|
||||||
{% trans "Create Inquiry" %}
|
{% trans "Create Inquiry" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'complaint_list' and 'callcenter' in request.path %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'complaint_list' and 'callcenter' in request.path %}active{% endif %}"
|
||||||
href="{% url 'callcenter:complaint_list' %}">
|
href="{% url 'callcenter:complaint_list' %}">
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
{% trans "Complaints" %}
|
{% trans "Complaints" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'inquiry_list' and 'callcenter' in request.path %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'inquiry_list' and 'callcenter' in request.path %}active{% endif %}"
|
||||||
href="{% url 'callcenter:inquiry_list' %}">
|
href="{% url 'callcenter:inquiry_list' %}">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
{% trans "Inquiries" %}
|
{% trans "Inquiries" %}
|
||||||
@ -224,47 +227,43 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Social Media -->
|
<!-- Social Media -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'social' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'social' in request.path %}active{% endif %}"
|
||||||
href="{% url 'social:mention_list' %}">
|
href="{% url 'social:mention_list' %}">
|
||||||
<i class="bi bi-chat-dots"></i>
|
<i class="bi bi-chat-dots"></i>
|
||||||
{% trans "Social Media" %}
|
{% trans "Social Media" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
|
||||||
<!-- Analytics -->
|
<!-- Analytics -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'analytics' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'analytics' in request.path %}active{% endif %}"
|
||||||
href="{% url 'analytics:dashboard' %}">
|
href="{% url 'analytics:dashboard' %}">
|
||||||
<i class="bi bi-graph-up"></i>
|
<i class="bi bi-graph-up"></i>
|
||||||
{% trans "Analytics" %}
|
{% trans "Analytics" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- QI Projects -->
|
<!-- QI Projects -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'projects' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'projects' in request.path %}active{% endif %}"
|
||||||
href="{% url 'projects:project_list' %}">
|
href="{% url 'projects:project_list' %}">
|
||||||
<i class="bi bi-kanban"></i>
|
<i class="bi bi-kanban"></i>
|
||||||
{% trans "QI Projects" %}
|
{% trans "QI Projects" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
|
||||||
<!-- Settings (PX Admin only) -->
|
<!-- Settings (PX Admin only) -->
|
||||||
{% if user.is_px_admin %}
|
{% if user.is_px_admin %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'config' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'config' in request.path %}active{% endif %}"
|
||||||
data-bs-toggle="collapse"
|
href="{% url 'config:dashboard' %}">
|
||||||
href="#settingsMenu"
|
|
||||||
role="button"
|
|
||||||
aria-expanded="{% if 'config' in request.path %}true{% else %}false{% endif %}"
|
|
||||||
aria-controls="settingsMenu">
|
|
||||||
<i class="bi bi-gear"></i>
|
<i class="bi bi-gear"></i>
|
||||||
{% trans "Settings" %}
|
{% trans "Settings" %}
|
||||||
<i class="bi bi-chevron-down ms-auto"></i>
|
<i class="bi bi-chevron-down ms-auto"></i>
|
||||||
@ -272,14 +271,14 @@
|
|||||||
<div class="collapse {% if 'config' in request.path %}show{% endif %}" id="settingsMenu">
|
<div class="collapse {% if 'config' in request.path %}show{% endif %}" id="settingsMenu">
|
||||||
<ul class="nav flex-column ms-3">
|
<ul class="nav flex-column ms-3">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'config_dashboard' %}active{% endif %}"
|
<a class="nav-link {% if request.resolver_match.url_name == 'config_dashboard' %}active{% endif %}"
|
||||||
href="{% url 'config:dashboard' %}">
|
href="{% url 'config:dashboard' %}">
|
||||||
<i class="bi bi-sliders"></i>
|
<i class="bi bi-sliders"></i>
|
||||||
{% trans "Configuration" %}
|
{% trans "Configuration" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'onboarding' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'onboarding' in request.path %}active{% endif %}"
|
||||||
href="{% url 'accounts:provisional-user-list' %}">
|
href="{% url 'accounts:provisional-user-list' %}">
|
||||||
<i class="bi bi-person-plus"></i>
|
<i class="bi bi-person-plus"></i>
|
||||||
{% trans "Onboarding" %}
|
{% trans "Onboarding" %}
|
||||||
@ -292,4 +291,71 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hospital Display -->
|
||||||
|
{% if current_hospital %}
|
||||||
|
<div class="hospital-selector px-3 py-2" style="border-top: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
{% if is_px_admin %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-light w-100 d-flex align-items-center text-start" type="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-hospital me-2 text-primary"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-semibold" style="font-size: 0.9rem;">
|
||||||
|
{{ current_hospital.name|truncatewords:3 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" style="font-size: 0.75rem;">
|
||||||
|
{% if current_hospital.city %}{{ current_hospital.city }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-chevron-down text-muted"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu w-100" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<li class="dropdown-header">
|
||||||
|
<i class="bi bi-building me-2"></i>{% trans "Switch Hospital" %}
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
{% get_all_hospitals as hospitals %}
|
||||||
|
{% for hospital in hospitals %}
|
||||||
|
<li>
|
||||||
|
<form action="{% url 'core:select_hospital' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="hospital_id" value="{{ hospital.id }}">
|
||||||
|
<button type="submit" class="dropdown-item d-flex align-items-center w-100{% if hospital.id == current_hospital.id %} active{% endif %}">
|
||||||
|
<i class="bi bi-hospital me-2 text-primary"></i>
|
||||||
|
<div class="text-start flex-grow-1">
|
||||||
|
<div class="fw-semibold" style="font-size: 0.9rem;">{{ hospital.name }}</div>
|
||||||
|
<div class="text-muted" style="font-size: 0.75rem;">
|
||||||
|
{% if hospital.city %}{{ hospital.city }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if hospital.id == current_hospital.id %}
|
||||||
|
<i class="bi bi-check2 text-success"></i>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'core:select_hospital' %}" class="dropdown-item">
|
||||||
|
<i class="bi bi-list-ul me-2"></i>{% trans "View All Hospitals" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-hospital me-2 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold" style="font-size: 0.9rem;">
|
||||||
|
{{ current_hospital.name|truncatewords:3 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" style="font-size: 0.75rem;">
|
||||||
|
{% if current_hospital.city %}{{ current_hospital.city }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,13 +4,13 @@
|
|||||||
<button class="btn btn-link text-teal d-lg-none me-3" type="button" onclick="document.querySelector('.sidebar').classList.toggle('show')">
|
<button class="btn btn-link text-teal d-lg-none me-3" type="button" onclick="document.querySelector('.sidebar').classList.toggle('show')">
|
||||||
<i class="bi bi-list fs-4"></i>
|
<i class="bi bi-list fs-4"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Page Title -->
|
<!-- Page Title -->
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<img src="{% static 'img/logo.png' %}" height="50">
|
<img src="{% static 'img/logo.png' %}" height="50">
|
||||||
{# <h5 class="mb-0 text-teal-dark">{% block page_title %}{% trans "Dashboard" %}{% endblock %}</h5>#}
|
{# <h5 class="mb-0 text-teal-dark">{% block page_title %}{% trans "Dashboard" %}{% endblock %}</h5>#}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="me-3 d-none d-md-block">
|
<div class="me-3 d-none d-md-block">
|
||||||
<div class="input-group" style="width: 300px;">
|
<div class="input-group" style="width: 300px;">
|
||||||
@ -20,7 +20,7 @@
|
|||||||
<input type="text" class="form-control border-start-0" placeholder="{% trans 'Search...' %}" style="border-color: var(--hh-border);">
|
<input type="text" class="form-control border-start-0" placeholder="{% trans 'Search...' %}" style="border-color: var(--hh-border);">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<div class="dropdown me-3">
|
<div class="dropdown me-3">
|
||||||
<button class="btn btn-link position-relative p-0 text-teal" type="button" data-bs-toggle="dropdown" style="line-height: 1;">
|
<button class="btn btn-link position-relative p-0 text-teal" type="button" data-bs-toggle="dropdown" style="line-height: 1;">
|
||||||
@ -52,7 +52,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<div class="dropdown me-3">
|
<div class="dropdown me-3">
|
||||||
<button class="btn btn-link text-teal" type="button" data-bs-toggle="dropdown">
|
<button class="btn btn-link text-teal" type="button" data-bs-toggle="dropdown">
|
||||||
@ -83,7 +83,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Menu -->
|
<!-- User Menu -->
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-link d-flex align-items-center text-decoration-none" type="button" data-bs-toggle="dropdown">
|
<button class="btn btn-link d-flex align-items-center text-decoration-none" type="button" data-bs-toggle="dropdown">
|
||||||
@ -91,9 +91,10 @@
|
|||||||
<div class="fw-semibold" style="color: var(--hh-text-dark);">{{ user.get_full_name|default:user.username }}</div>
|
<div class="fw-semibold" style="color: var(--hh-text-dark);">{{ user.get_full_name|default:user.username }}</div>
|
||||||
<small style="color: var(--hh-text-muted);">{{ user.get_role_names.0|default:"User" }}</small>
|
<small style="color: var(--hh-text-muted);">{{ user.get_role_names.0|default:"User" }}</small>
|
||||||
</div>
|
</div>
|
||||||
{# <div class="avatar avatar-teal">#}
|
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center"
|
||||||
{# {{ user.first_name.0|default:user.username.0|upper }}#}
|
style="width: 40px; height: 40px;">
|
||||||
{# </div>#}
|
{{ user.first_name.0|default:user.username.0|upper }}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li class="dropdown-header">
|
<li class="dropdown-header">
|
||||||
|
|||||||
88
templates/layouts/public_base.html
Normal file
88
templates/layouts/public_base.html
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title>{% block title %}{% trans "PX360 - Patient Experience Management" %}{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-header {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-footer {
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
padding: 2rem 0;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="public-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<h3 class="mb-0">
|
||||||
|
<i class="fas fa-heartbeat text-primary"></i> PX360
|
||||||
|
</h3>
|
||||||
|
<span class="text-muted">
|
||||||
|
{% trans "Patient Experience Management" %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main>
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="public-footer">
|
||||||
|
<div class="container text-center">
|
||||||
|
<p class="mb-0 text-muted">
|
||||||
|
© {% now "Y" %} PX360. {% trans "All rights reserved." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- jQuery -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user