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=
|
||||
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
|
||||
SMS_ENABLED=False
|
||||
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/
|
||||
|
||||
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.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
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -43,13 +43,13 @@ User = get_user_model()
|
||||
|
||||
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):
|
||||
response = super().post(request, *args, **kwargs)
|
||||
|
||||
# Log successful login
|
||||
|
||||
# Log successful login and add redirect info
|
||||
if response.status_code == 200:
|
||||
username = request.data.get('username')
|
||||
try:
|
||||
@ -60,16 +60,41 @@ class CustomTokenObtainPairView(TokenObtainPairView):
|
||||
request=request,
|
||||
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:
|
||||
pass
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
ViewSet for User model.
|
||||
|
||||
|
||||
Permissions:
|
||||
- List/Retrieve: Authenticated users
|
||||
- Create/Update/Delete: PX Admins only
|
||||
@ -81,7 +106,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
search_fields = ['username', 'email', 'first_name', 'last_name', 'employee_id']
|
||||
ordering_fields = ['date_joined', 'email', 'last_name']
|
||||
ordering = ['-date_joined']
|
||||
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action"""
|
||||
if self.action == 'create':
|
||||
@ -89,7 +114,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
elif self.action in ['update', 'partial_update']:
|
||||
return UserUpdateSerializer
|
||||
return UserSerializer
|
||||
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action"""
|
||||
if self.action in ['create', 'destroy']:
|
||||
@ -97,27 +122,27 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
elif self.action in ['update', 'partial_update']:
|
||||
return [IsOwnerOrPXAdmin()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter queryset based on user role"""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all users
|
||||
if user.is_px_admin():
|
||||
return queryset.select_related('hospital', 'department')
|
||||
|
||||
|
||||
# Hospital Admins see users in their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital).select_related('hospital', 'department')
|
||||
|
||||
|
||||
# Department Managers see users in their department
|
||||
if user.is_department_manager() and user.department:
|
||||
return queryset.filter(department=user.department).select_related('hospital', 'department')
|
||||
|
||||
|
||||
# Others see only themselves
|
||||
return queryset.filter(id=user.id)
|
||||
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Log user creation"""
|
||||
user = serializer.save()
|
||||
@ -127,7 +152,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
request=self.request,
|
||||
content_object=user
|
||||
)
|
||||
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Log user update"""
|
||||
user = serializer.save()
|
||||
@ -137,58 +162,58 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
request=self.request,
|
||||
content_object=user
|
||||
)
|
||||
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||
def me(self, request):
|
||||
"""Get current user profile"""
|
||||
serializer = self.get_serializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@action(detail=False, methods=['put'], permission_classes=[IsAuthenticated])
|
||||
def update_profile(self, request):
|
||||
"""Update current user profile"""
|
||||
serializer = UserUpdateSerializer(request.user, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='other',
|
||||
description=f"User {request.user.email} updated their profile",
|
||||
request=request,
|
||||
content_object=request.user
|
||||
)
|
||||
|
||||
|
||||
return Response(UserSerializer(request.user).data)
|
||||
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
|
||||
def change_password(self, request):
|
||||
"""Change user password"""
|
||||
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
# Change password
|
||||
request.user.set_password(serializer.validated_data['new_password'])
|
||||
request.user.save()
|
||||
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='other',
|
||||
description=f"User {request.user.email} changed their password",
|
||||
request=request,
|
||||
content_object=request.user
|
||||
)
|
||||
|
||||
|
||||
return Response({'message': 'Password changed successfully'}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
|
||||
def assign_role(self, request, pk=None):
|
||||
"""Assign role to user (PX Admin only)"""
|
||||
user = self.get_object()
|
||||
role_id = request.data.get('role_id')
|
||||
|
||||
|
||||
try:
|
||||
role = Role.objects.get(id=role_id)
|
||||
user.groups.add(role.group)
|
||||
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='role_change',
|
||||
description=f"Role {role.display_name} assigned to user {user.email}",
|
||||
@ -196,21 +221,21 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
content_object=user,
|
||||
metadata={'role': role.name}
|
||||
)
|
||||
|
||||
|
||||
return Response({'message': f'Role {role.display_name} assigned successfully'})
|
||||
except Role.DoesNotExist:
|
||||
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
|
||||
def remove_role(self, request, pk=None):
|
||||
"""Remove role from user (PX Admin only)"""
|
||||
user = self.get_object()
|
||||
role_id = request.data.get('role_id')
|
||||
|
||||
|
||||
try:
|
||||
role = Role.objects.get(id=role_id)
|
||||
user.groups.remove(role.group)
|
||||
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='role_change',
|
||||
description=f"Role {role.display_name} removed from user {user.email}",
|
||||
@ -218,7 +243,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
content_object=user,
|
||||
metadata={'role': role.name}
|
||||
)
|
||||
|
||||
|
||||
return Response({'message': f'Role {role.display_name} removed successfully'})
|
||||
except Role.DoesNotExist:
|
||||
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
@ -227,7 +252,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
class RoleViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Role model.
|
||||
|
||||
|
||||
Permissions:
|
||||
- List/Retrieve: Authenticated users
|
||||
- Create/Update/Delete: PX Admins only
|
||||
@ -239,7 +264,7 @@ class RoleViewSet(viewsets.ModelViewSet):
|
||||
search_fields = ['name', 'display_name', 'description']
|
||||
ordering_fields = ['level', 'name']
|
||||
ordering = ['-level', 'name']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
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 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 uuid
|
||||
|
||||
@ -12,7 +12,7 @@ from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.complaints.models import Complaint, ComplaintSource, Inquiry
|
||||
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
|
||||
|
||||
@ -23,7 +23,7 @@ def interaction_list(request):
|
||||
queryset = CallCenterInteraction.objects.select_related(
|
||||
'patient', 'hospital', 'department', 'agent'
|
||||
)
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if user.is_px_admin():
|
||||
@ -32,20 +32,20 @@ def interaction_list(request):
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
|
||||
# Apply filters
|
||||
call_type_filter = request.GET.get('call_type')
|
||||
if call_type_filter:
|
||||
queryset = queryset.filter(call_type=call_type_filter)
|
||||
|
||||
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
|
||||
is_low_rating = request.GET.get('is_low_rating')
|
||||
if is_low_rating == 'true':
|
||||
queryset = queryset.filter(is_low_rating=True)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
@ -54,30 +54,30 @@ def interaction_list(request):
|
||||
Q(caller_name__icontains=search_query) |
|
||||
Q(patient__mrn__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Date range
|
||||
date_from = request.GET.get('date_from')
|
||||
if date_from:
|
||||
queryset = queryset.filter(call_started_at__gte=date_from)
|
||||
|
||||
|
||||
date_to = request.GET.get('date_to')
|
||||
if date_to:
|
||||
queryset = queryset.filter(call_started_at__lte=date_to)
|
||||
|
||||
|
||||
# Ordering
|
||||
queryset = queryset.order_by('-call_started_at')
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
'total': queryset.count(),
|
||||
@ -86,7 +86,7 @@ def interaction_list(request):
|
||||
avg=Avg('satisfaction_rating')
|
||||
)['avg'] or 0,
|
||||
}
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'interactions': page_obj.object_list,
|
||||
@ -94,7 +94,7 @@ def interaction_list(request):
|
||||
'hospitals': hospitals,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'callcenter/interaction_list.html', context)
|
||||
|
||||
|
||||
@ -107,11 +107,11 @@ def interaction_detail(request, pk):
|
||||
),
|
||||
pk=pk
|
||||
)
|
||||
|
||||
|
||||
context = {
|
||||
'interaction': interaction,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'callcenter/interaction_detail.html', context)
|
||||
|
||||
|
||||
@ -124,7 +124,7 @@ def interaction_detail(request, pk):
|
||||
def create_complaint(request):
|
||||
"""
|
||||
Create complaint from call center interaction.
|
||||
|
||||
|
||||
Call center staff can create complaints on behalf of patients/callers.
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
@ -133,8 +133,8 @@ def create_complaint(request):
|
||||
patient_id = request.POST.get('patient_id', None)
|
||||
hospital_id = request.POST.get('hospital_id')
|
||||
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')
|
||||
description = request.POST.get('description')
|
||||
category = request.POST.get('category')
|
||||
@ -142,28 +142,28 @@ def create_complaint(request):
|
||||
priority = request.POST.get('priority')
|
||||
severity = request.POST.get('severity')
|
||||
encounter_id = request.POST.get('encounter_id', '')
|
||||
|
||||
|
||||
# Call center specific fields
|
||||
caller_name = request.POST.get('caller_name', '')
|
||||
caller_phone = request.POST.get('caller_phone', '')
|
||||
caller_relationship = request.POST.get('caller_relationship', 'patient')
|
||||
|
||||
|
||||
# Validate required fields
|
||||
if not all([hospital_id, title, description, category, priority, severity]):
|
||||
messages.error(request, "Please fill in all required fields.")
|
||||
return redirect('callcenter:create_complaint')
|
||||
|
||||
|
||||
# If no patient selected, we need caller info
|
||||
if not patient_id and not caller_name:
|
||||
messages.error(request, "Please provide either patient or caller information.")
|
||||
return redirect('callcenter:create_complaint')
|
||||
|
||||
|
||||
# Create complaint
|
||||
complaint = Complaint.objects.create(
|
||||
patient_id=patient_id if patient_id else None,
|
||||
hospital_id=hospital_id,
|
||||
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,
|
||||
description=description,
|
||||
category=category,
|
||||
@ -173,7 +173,7 @@ def create_complaint(request):
|
||||
source=ComplaintSource.CALL_CENTER,
|
||||
encounter_id=encounter_id,
|
||||
)
|
||||
|
||||
|
||||
# Create call center interaction record
|
||||
CallCenterInteraction.objects.create(
|
||||
patient_id=patient_id if patient_id else None,
|
||||
@ -192,7 +192,7 @@ def create_complaint(request):
|
||||
'severity': severity,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='complaint_created',
|
||||
@ -206,23 +206,23 @@ def create_complaint(request):
|
||||
'caller_name': caller_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, f"Complaint #{complaint.id} created successfully.")
|
||||
return redirect('callcenter:complaint_success', pk=complaint.id)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating complaint: {str(e)}")
|
||||
return redirect('callcenter:create_complaint')
|
||||
|
||||
|
||||
# GET request - show form
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not request.user.is_px_admin() and request.user.hospital:
|
||||
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||
|
||||
|
||||
context = {
|
||||
'hospitals': hospitals,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'callcenter/complaint_form.html', context)
|
||||
|
||||
|
||||
@ -230,11 +230,11 @@ def create_complaint(request):
|
||||
def complaint_success(request, pk):
|
||||
"""Success page after creating complaint"""
|
||||
complaint = get_object_or_404(Complaint, pk=pk)
|
||||
|
||||
|
||||
context = {
|
||||
'complaint': complaint,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'callcenter/complaint_success.html', context)
|
||||
|
||||
|
||||
@ -244,9 +244,9 @@ def complaint_list(request):
|
||||
queryset = Complaint.objects.filter(
|
||||
source=ComplaintSource.CALL_CENTER
|
||||
).select_related(
|
||||
'patient', 'hospital', 'department', 'physician', 'assigned_to'
|
||||
'patient', 'hospital', 'department', 'staff', 'assigned_to'
|
||||
)
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if user.is_px_admin():
|
||||
@ -255,20 +255,20 @@ def complaint_list(request):
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
severity_filter = request.GET.get('severity')
|
||||
if severity_filter:
|
||||
queryset = queryset.filter(severity=severity_filter)
|
||||
|
||||
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
@ -277,21 +277,21 @@ def complaint_list(request):
|
||||
Q(description__icontains=search_query) |
|
||||
Q(patient__mrn__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Ordering
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
'total': queryset.count(),
|
||||
@ -299,7 +299,7 @@ def complaint_list(request):
|
||||
'in_progress': queryset.filter(status='in_progress').count(),
|
||||
'resolved': queryset.filter(status='resolved').count(),
|
||||
}
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'complaints': page_obj.object_list,
|
||||
@ -307,7 +307,7 @@ def complaint_list(request):
|
||||
'hospitals': hospitals,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'callcenter/complaint_list.html', context)
|
||||
|
||||
|
||||
@ -320,7 +320,7 @@ def complaint_list(request):
|
||||
def create_inquiry(request):
|
||||
"""
|
||||
Create inquiry from call center interaction.
|
||||
|
||||
|
||||
Call center staff can create inquiries for general questions/requests.
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
@ -329,29 +329,29 @@ def create_inquiry(request):
|
||||
patient_id = request.POST.get('patient_id', None)
|
||||
hospital_id = request.POST.get('hospital_id')
|
||||
department_id = request.POST.get('department_id', None)
|
||||
|
||||
|
||||
subject = request.POST.get('subject')
|
||||
message = request.POST.get('message')
|
||||
category = request.POST.get('category')
|
||||
|
||||
|
||||
# Contact info (if no patient)
|
||||
contact_name = request.POST.get('contact_name', '')
|
||||
contact_phone = request.POST.get('contact_phone', '')
|
||||
contact_email = request.POST.get('contact_email', '')
|
||||
|
||||
|
||||
# Call center specific
|
||||
caller_relationship = request.POST.get('caller_relationship', 'patient')
|
||||
|
||||
|
||||
# Validate required fields
|
||||
if not all([hospital_id, subject, message, category]):
|
||||
messages.error(request, "Please fill in all required fields.")
|
||||
return redirect('callcenter:create_inquiry')
|
||||
|
||||
|
||||
# If no patient, need contact info
|
||||
if not patient_id and not contact_name:
|
||||
messages.error(request, "Please provide either patient or contact information.")
|
||||
return redirect('callcenter:create_inquiry')
|
||||
|
||||
|
||||
# Create inquiry
|
||||
inquiry = Inquiry.objects.create(
|
||||
patient_id=patient_id if patient_id else None,
|
||||
@ -364,7 +364,7 @@ def create_inquiry(request):
|
||||
contact_phone=contact_phone,
|
||||
contact_email=contact_email,
|
||||
)
|
||||
|
||||
|
||||
# Create call center interaction record
|
||||
CallCenterInteraction.objects.create(
|
||||
patient_id=patient_id if patient_id else None,
|
||||
@ -382,7 +382,7 @@ def create_inquiry(request):
|
||||
'category': category,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='inquiry_created',
|
||||
@ -395,23 +395,23 @@ def create_inquiry(request):
|
||||
'contact_name': contact_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, f"Inquiry #{inquiry.id} created successfully.")
|
||||
return redirect('callcenter:inquiry_success', pk=inquiry.id)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating inquiry: {str(e)}")
|
||||
return redirect('callcenter:create_inquiry')
|
||||
|
||||
|
||||
# GET request - show form
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not request.user.is_px_admin() and request.user.hospital:
|
||||
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||
|
||||
|
||||
context = {
|
||||
'hospitals': hospitals,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'callcenter/inquiry_form.html', context)
|
||||
|
||||
|
||||
@ -419,11 +419,11 @@ def create_inquiry(request):
|
||||
def inquiry_success(request, pk):
|
||||
"""Success page after creating inquiry"""
|
||||
inquiry = get_object_or_404(Inquiry, pk=pk)
|
||||
|
||||
|
||||
context = {
|
||||
'inquiry': inquiry,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'callcenter/inquiry_success.html', context)
|
||||
|
||||
|
||||
@ -433,7 +433,7 @@ def inquiry_list(request):
|
||||
queryset = Inquiry.objects.select_related(
|
||||
'patient', 'hospital', 'department', 'assigned_to', 'responded_by'
|
||||
)
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if user.is_px_admin():
|
||||
@ -442,20 +442,20 @@ def inquiry_list(request):
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
category_filter = request.GET.get('category')
|
||||
if category_filter:
|
||||
queryset = queryset.filter(category=category_filter)
|
||||
|
||||
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
@ -464,21 +464,21 @@ def inquiry_list(request):
|
||||
Q(message__icontains=search_query) |
|
||||
Q(contact_name__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Ordering
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
'total': queryset.count(),
|
||||
@ -486,7 +486,7 @@ def inquiry_list(request):
|
||||
'in_progress': queryset.filter(status='in_progress').count(),
|
||||
'resolved': queryset.filter(status='resolved').count(),
|
||||
}
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'inquiries': page_obj.object_list,
|
||||
@ -494,7 +494,7 @@ def inquiry_list(request):
|
||||
'hospitals': hospitals,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
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')
|
||||
if not hospital_id:
|
||||
return JsonResponse({'departments': []})
|
||||
|
||||
|
||||
departments = Department.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
<<<<<<< HEAD
|
||||
).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)})
|
||||
|
||||
|
||||
@login_required
|
||||
def get_physicians_by_hospital(request):
|
||||
"""Get physicians for a hospital (AJAX)"""
|
||||
def get_staff_by_hospital(request):
|
||||
"""Get staff for a hospital (AJAX)"""
|
||||
hospital_id = request.GET.get('hospital_id')
|
||||
if not hospital_id:
|
||||
return JsonResponse({'physicians': []})
|
||||
|
||||
physicians = Physician.objects.filter(
|
||||
return JsonResponse({'staff': []})
|
||||
|
||||
staff_members = Staff.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
<<<<<<< HEAD
|
||||
).values('id', 'first_name', 'last_name', 'specialization')
|
||||
|
||||
# Format physician names
|
||||
@ -535,11 +541,22 @@ def get_physicians_by_hospital(request):
|
||||
'id': str(p['id']),
|
||||
'name': f"Dr. {p['first_name']} {p['last_name']}",
|
||||
'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
|
||||
@ -547,10 +564,10 @@ def search_patients(request):
|
||||
"""Search patients by MRN or name (AJAX)"""
|
||||
query = request.GET.get('q', '')
|
||||
hospital_id = request.GET.get('hospital_id', None)
|
||||
|
||||
|
||||
if len(query) < 2:
|
||||
return JsonResponse({'patients': []})
|
||||
|
||||
|
||||
patients = Patient.objects.filter(
|
||||
Q(mrn__icontains=query) |
|
||||
Q(first_name__icontains=query) |
|
||||
@ -558,12 +575,12 @@ def search_patients(request):
|
||||
Q(national_id__icontains=query) |
|
||||
Q(phone__icontains=query)
|
||||
)
|
||||
|
||||
|
||||
if hospital_id:
|
||||
patients = patients.filter(hospital_id=hospital_id)
|
||||
|
||||
|
||||
patients = patients[:20]
|
||||
|
||||
|
||||
results = [
|
||||
{
|
||||
'id': str(p.id),
|
||||
@ -575,5 +592,5 @@ def search_patients(request):
|
||||
}
|
||||
for p in patients
|
||||
]
|
||||
|
||||
|
||||
return JsonResponse({'patients': results})
|
||||
|
||||
@ -7,19 +7,19 @@ urlpatterns = [
|
||||
# Interactions
|
||||
path('interactions/', ui_views.interaction_list, name='interaction_list'),
|
||||
path('interactions/<uuid:pk>/', ui_views.interaction_detail, name='interaction_detail'),
|
||||
|
||||
|
||||
# Complaints
|
||||
path('complaints/', ui_views.complaint_list, name='complaint_list'),
|
||||
path('complaints/create/', ui_views.create_complaint, name='create_complaint'),
|
||||
path('complaints/<uuid:pk>/success/', ui_views.complaint_success, name='complaint_success'),
|
||||
|
||||
|
||||
# Inquiries
|
||||
path('inquiries/', ui_views.inquiry_list, name='inquiry_list'),
|
||||
path('inquiries/create/', ui_views.create_inquiry, name='create_inquiry'),
|
||||
path('inquiries/<uuid:pk>/success/', ui_views.inquiry_success, name='inquiry_success'),
|
||||
|
||||
|
||||
# AJAX Helpers
|
||||
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'),
|
||||
]
|
||||
|
||||
@ -52,13 +52,13 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
ordering = ['-created_at']
|
||||
date_hierarchy = 'created_at'
|
||||
inlines = [ComplaintUpdateInline, ComplaintAttachmentInline]
|
||||
|
||||
|
||||
fieldsets = (
|
||||
('Patient & Encounter', {
|
||||
'fields': ('patient', 'encounter_id')
|
||||
}),
|
||||
('Organization', {
|
||||
'fields': ('hospital', 'department', 'physician')
|
||||
'fields': ('hospital', 'department', 'staff')
|
||||
}),
|
||||
('Complaint Details', {
|
||||
'fields': ('title', 'description', 'category', 'subcategory')
|
||||
@ -83,25 +83,25 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = [
|
||||
'assigned_at', 'reminder_sent_at', 'escalated_at',
|
||||
'resolved_at', 'closed_at', 'resolution_survey_sent_at',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related(
|
||||
'patient', 'hospital', 'department', 'physician',
|
||||
'patient', 'hospital', 'department', 'staff',
|
||||
'assigned_to', 'resolved_by', 'closed_by', 'resolution_survey'
|
||||
)
|
||||
|
||||
|
||||
def title_preview(self, obj):
|
||||
"""Show preview of title"""
|
||||
return obj.title[:60] + '...' if len(obj.title) > 60 else obj.title
|
||||
title_preview.short_description = 'Title'
|
||||
|
||||
|
||||
def severity_badge(self, obj):
|
||||
"""Display severity with color badge"""
|
||||
colors = {
|
||||
@ -117,7 +117,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
obj.get_severity_display()
|
||||
)
|
||||
severity_badge.short_description = 'Severity'
|
||||
|
||||
|
||||
def status_badge(self, obj):
|
||||
"""Display status with color badge"""
|
||||
colors = {
|
||||
@ -134,16 +134,16 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
obj.get_status_display()
|
||||
)
|
||||
status_badge.short_description = 'Status'
|
||||
|
||||
|
||||
def sla_indicator(self, obj):
|
||||
"""Display SLA status"""
|
||||
if obj.is_overdue:
|
||||
return format_html('<span class="badge bg-danger">OVERDUE</span>')
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
time_remaining = obj.due_at - timezone.now()
|
||||
hours_remaining = time_remaining.total_seconds() / 3600
|
||||
|
||||
|
||||
if hours_remaining < 4:
|
||||
return format_html('<span class="badge bg-warning">DUE SOON</span>')
|
||||
else:
|
||||
@ -158,7 +158,7 @@ class ComplaintAttachmentAdmin(admin.ModelAdmin):
|
||||
list_filter = ['file_type', 'created_at']
|
||||
search_fields = ['filename', 'description', 'complaint__title']
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('complaint', 'file', 'filename', 'file_type', 'file_size')
|
||||
@ -170,9 +170,9 @@ class ComplaintAttachmentAdmin(admin.ModelAdmin):
|
||||
'fields': ('created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['file_size', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('complaint', 'uploaded_by')
|
||||
@ -185,7 +185,7 @@ class ComplaintUpdateAdmin(admin.ModelAdmin):
|
||||
list_filter = ['update_type', 'created_at']
|
||||
search_fields = ['message', 'complaint__title']
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('complaint', 'update_type', 'message')
|
||||
@ -201,13 +201,13 @@ class ComplaintUpdateAdmin(admin.ModelAdmin):
|
||||
'fields': ('created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('complaint', 'created_by')
|
||||
|
||||
|
||||
def message_preview(self, obj):
|
||||
"""Show preview of 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'
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
('Patient Information', {
|
||||
'fields': ('patient',)
|
||||
@ -252,16 +252,16 @@ class InquiryAdmin(admin.ModelAdmin):
|
||||
'fields': ('created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['responded_at', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related(
|
||||
'patient', 'hospital', 'department',
|
||||
'assigned_to', 'responded_by'
|
||||
)
|
||||
|
||||
|
||||
def subject_preview(self, obj):
|
||||
"""Show preview of 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']
|
||||
search_fields = ['hospital__name_en', 'hospital__name_ar']
|
||||
ordering = ['hospital', 'severity', 'priority']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
('Hospital', {
|
||||
'fields': ('hospital',)
|
||||
@ -297,9 +297,9 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('hospital')
|
||||
@ -309,16 +309,16 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin):
|
||||
class ComplaintCategoryAdmin(admin.ModelAdmin):
|
||||
"""Complaint Category admin"""
|
||||
list_display = [
|
||||
'name_en', 'code', 'hospital', 'parent',
|
||||
'name_en', 'code', 'hospitals_display', 'parent',
|
||||
'order', 'is_active'
|
||||
]
|
||||
list_filter = ['hospital', 'is_active', 'parent']
|
||||
list_filter = ['is_active', 'parent']
|
||||
search_fields = ['name_en', 'name_ar', 'code', 'description_en']
|
||||
ordering = ['hospital', 'order', 'name_en']
|
||||
|
||||
ordering = ['order', 'name_en']
|
||||
|
||||
fieldsets = (
|
||||
('Hospital', {
|
||||
'fields': ('hospital',)
|
||||
('Hospitals', {
|
||||
'fields': ('hospitals',)
|
||||
}),
|
||||
('Category Details', {
|
||||
'fields': ('code', 'name_en', 'name_ar')
|
||||
@ -338,12 +338,24 @@ class ComplaintCategoryAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
filter_horizontal = ['hospitals']
|
||||
|
||||
def get_queryset(self, 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)
|
||||
@ -359,7 +371,7 @@ class EscalationRuleAdmin(admin.ModelAdmin):
|
||||
]
|
||||
search_fields = ['name', 'description', 'hospital__name_en']
|
||||
ordering = ['hospital', 'order']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
('Hospital', {
|
||||
'fields': ('hospital',)
|
||||
@ -385,9 +397,9 @@ class EscalationRuleAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('hospital', 'escalate_to_user')
|
||||
@ -406,7 +418,7 @@ class ComplaintThresholdAdmin(admin.ModelAdmin):
|
||||
]
|
||||
search_fields = ['hospital__name_en', 'hospital__name_ar']
|
||||
ordering = ['hospital', 'threshold_type']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
('Hospital', {
|
||||
'fields': ('hospital',)
|
||||
@ -425,13 +437,13 @@ class ComplaintThresholdAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('hospital')
|
||||
|
||||
|
||||
def comparison_display(self, obj):
|
||||
"""Display comparison operator"""
|
||||
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 uuid
|
||||
@ -12,21 +12,147 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organizations', '0001_initial'),
|
||||
('surveys', '0002_surveyquestion_surveyresponse_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ComplaintAttachment',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('file', models.FileField(upload_to='complaints/%Y/%m/%d/')),
|
||||
('filename', models.CharField(max_length=500)),
|
||||
('file_type', models.CharField(blank=True, max_length=100)),
|
||||
('file_size', models.IntegerField(help_text='File size in bytes')),
|
||||
('description', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ComplaintCategory',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('code', models.CharField(help_text='Unique code for this category', max_length=50)),
|
||||
('name_en', models.CharField(max_length=200)),
|
||||
('name_ar', models.CharField(blank=True, max_length=200)),
|
||||
('description_en', models.TextField(blank=True)),
|
||||
('description_ar', models.TextField(blank=True)),
|
||||
('order', models.IntegerField(default=0, help_text='Display order')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Complaint Categories',
|
||||
'ordering': ['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(
|
||||
name='Complaint',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('contact_name', models.CharField(blank=True, max_length=200)),
|
||||
('contact_phone', models.CharField(blank=True, max_length=20)),
|
||||
('contact_email', models.EmailField(blank=True, max_length=254)),
|
||||
('reference_number', models.CharField(blank=True, db_index=True, help_text='Unique reference number for patient tracking', max_length=50, null=True, unique=True)),
|
||||
('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)),
|
||||
('title', models.CharField(max_length=500)),
|
||||
('description', models.TextField()),
|
||||
('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)),
|
||||
('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)),
|
||||
@ -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)),
|
||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.department')),
|
||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.hospital')),
|
||||
('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, 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)),
|
||||
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.patient')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
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.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):
|
||||
@ -39,16 +39,74 @@ class ComplaintSource(models.TextChoices):
|
||||
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):
|
||||
"""
|
||||
Complaint model with SLA tracking.
|
||||
|
||||
|
||||
Workflow:
|
||||
1. OPEN - Complaint received
|
||||
2. IN_PROGRESS - Being investigated
|
||||
3. RESOLVED - Solution provided
|
||||
4. CLOSED - Confirmed closed (triggers resolution satisfaction survey)
|
||||
|
||||
|
||||
SLA:
|
||||
- Calculated based on severity and hospital configuration
|
||||
- Reminders sent before due date
|
||||
@ -57,16 +115,34 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
# Patient and encounter information
|
||||
patient = models.ForeignKey(
|
||||
'organizations.Patient',
|
||||
on_delete=models.CASCADE,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
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(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Related encounter ID if applicable"
|
||||
)
|
||||
|
||||
|
||||
# Organization
|
||||
hospital = models.ForeignKey(
|
||||
'organizations.Hospital',
|
||||
@ -80,34 +156,28 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='complaints'
|
||||
)
|
||||
physician = models.ForeignKey(
|
||||
'organizations.Physician',
|
||||
staff = models.ForeignKey(
|
||||
'organizations.Staff',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='complaints'
|
||||
)
|
||||
|
||||
|
||||
# Complaint details
|
||||
title = models.CharField(max_length=500)
|
||||
description = models.TextField()
|
||||
|
||||
|
||||
# Classification
|
||||
category = models.CharField(
|
||||
max_length=100,
|
||||
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
|
||||
category = models.ForeignKey(
|
||||
ComplaintCategory,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='complaints',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
subcategory = models.CharField(max_length=100, blank=True)
|
||||
|
||||
|
||||
# Priority and severity
|
||||
priority = models.CharField(
|
||||
max_length=20,
|
||||
@ -121,7 +191,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
default=SeverityChoices.MEDIUM,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
# Source
|
||||
source = models.CharField(
|
||||
max_length=50,
|
||||
@ -129,7 +199,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
default=ComplaintSource.PATIENT,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
# Status and workflow
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
@ -137,7 +207,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
default=ComplaintStatus.OPEN,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
# Assignment
|
||||
assigned_to = models.ForeignKey(
|
||||
'accounts.User',
|
||||
@ -147,7 +217,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
related_name='assigned_complaints'
|
||||
)
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
# SLA tracking
|
||||
due_at = models.DateTimeField(
|
||||
db_index=True,
|
||||
@ -156,7 +226,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
is_overdue = models.BooleanField(default=False, db_index=True)
|
||||
reminder_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
escalated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
# Resolution
|
||||
resolution = models.TextField(blank=True)
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
@ -167,7 +237,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='resolved_complaints'
|
||||
)
|
||||
|
||||
|
||||
# Closure
|
||||
closed_at = models.DateTimeField(null=True, blank=True)
|
||||
closed_by = models.ForeignKey(
|
||||
@ -177,7 +247,7 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='closed_complaints'
|
||||
)
|
||||
|
||||
|
||||
# Resolution satisfaction survey
|
||||
resolution_survey = models.ForeignKey(
|
||||
'surveys.SurveyInstance',
|
||||
@ -187,10 +257,10 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
related_name='complaint_resolution'
|
||||
)
|
||||
resolution_survey_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
@ -199,20 +269,20 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
models.Index(fields=['is_overdue', 'status']),
|
||||
models.Index(fields=['due_at', 'status']),
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
"""Calculate SLA due date on creation"""
|
||||
if not self.due_at:
|
||||
self.due_at = self.calculate_sla_due_date()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def calculate_sla_due_date(self):
|
||||
"""
|
||||
Calculate SLA due date based on severity and hospital configuration.
|
||||
|
||||
|
||||
First tries to use ComplaintSLAConfig from database.
|
||||
Falls back to settings.SLA_DEFAULTS if no config exists.
|
||||
"""
|
||||
@ -231,14 +301,14 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
self.severity,
|
||||
settings.SLA_DEFAULTS['complaint']['medium']
|
||||
)
|
||||
|
||||
|
||||
return timezone.now() + timedelta(hours=sla_hours)
|
||||
|
||||
|
||||
def check_overdue(self):
|
||||
"""Check if complaint is overdue and update status"""
|
||||
if self.status in [ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED]:
|
||||
return False
|
||||
|
||||
|
||||
if timezone.now() > self.due_at:
|
||||
if not self.is_overdue:
|
||||
self.is_overdue = True
|
||||
@ -246,6 +316,117 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
return True
|
||||
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):
|
||||
"""Complaint attachment (images, documents, etc.)"""
|
||||
@ -254,24 +435,24 @@ class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='attachments'
|
||||
)
|
||||
|
||||
|
||||
file = models.FileField(upload_to='complaints/%Y/%m/%d/')
|
||||
filename = models.CharField(max_length=500)
|
||||
file_type = models.CharField(max_length=100, blank=True)
|
||||
file_size = models.IntegerField(help_text="File size in bytes")
|
||||
|
||||
|
||||
uploaded_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='complaint_attachments'
|
||||
)
|
||||
|
||||
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.complaint} - {self.filename}"
|
||||
|
||||
@ -279,7 +460,7 @@ class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
||||
class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Complaint update/timeline entry.
|
||||
|
||||
|
||||
Tracks all updates, status changes, and communications.
|
||||
"""
|
||||
complaint = models.ForeignKey(
|
||||
@ -287,7 +468,7 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='updates'
|
||||
)
|
||||
|
||||
|
||||
# Update details
|
||||
update_type = models.CharField(
|
||||
max_length=50,
|
||||
@ -301,9 +482,9 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
||||
],
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
message = models.TextField()
|
||||
|
||||
|
||||
# User who made the update
|
||||
created_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
@ -311,20 +492,20 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
||||
null=True,
|
||||
related_name='complaint_updates'
|
||||
)
|
||||
|
||||
|
||||
# Status change tracking
|
||||
old_status = models.CharField(max_length=20, blank=True)
|
||||
new_status = models.CharField(max_length=20, blank=True)
|
||||
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['complaint', '-created_at']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
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):
|
||||
"""
|
||||
SLA configuration for complaints per hospital, severity, and priority.
|
||||
|
||||
|
||||
Allows flexible SLA configuration instead of hardcoded values.
|
||||
"""
|
||||
hospital = models.ForeignKey(
|
||||
@ -340,100 +521,47 @@ class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='complaint_sla_configs'
|
||||
)
|
||||
|
||||
|
||||
severity = models.CharField(
|
||||
max_length=20,
|
||||
choices=SeverityChoices.choices,
|
||||
help_text="Severity level for this SLA"
|
||||
)
|
||||
|
||||
|
||||
priority = models.CharField(
|
||||
max_length=20,
|
||||
choices=PriorityChoices.choices,
|
||||
help_text="Priority level for this SLA"
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['hospital', 'severity', 'priority']
|
||||
unique_together = [['hospital', 'severity', 'priority']]
|
||||
indexes = [
|
||||
models.Index(fields=['hospital', 'is_active']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
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):
|
||||
"""
|
||||
Configurable escalation rules for complaints.
|
||||
|
||||
|
||||
Defines who receives escalated complaints based on conditions.
|
||||
"""
|
||||
hospital = models.ForeignKey(
|
||||
@ -441,21 +569,21 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='escalation_rules'
|
||||
)
|
||||
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
|
||||
# Trigger conditions
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
# Escalation target
|
||||
escalate_to_role = models.CharField(
|
||||
max_length=50,
|
||||
@ -467,7 +595,7 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
||||
],
|
||||
help_text="Role to escalate to"
|
||||
)
|
||||
|
||||
|
||||
escalate_to_user = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -476,7 +604,7 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
||||
related_name='escalation_target_rules',
|
||||
help_text="Specific user if escalate_to_role is 'specific_user'"
|
||||
)
|
||||
|
||||
|
||||
# Conditions
|
||||
severity_filter = models.CharField(
|
||||
max_length=20,
|
||||
@ -484,27 +612,27 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
help_text="Only escalate complaints with this severity (blank = all)"
|
||||
)
|
||||
|
||||
|
||||
priority_filter = models.CharField(
|
||||
max_length=20,
|
||||
choices=PriorityChoices.choices,
|
||||
blank=True,
|
||||
help_text="Only escalate complaints with this priority (blank = all)"
|
||||
)
|
||||
|
||||
|
||||
order = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Escalation order (lower = first)"
|
||||
)
|
||||
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['hospital', 'order']
|
||||
indexes = [
|
||||
models.Index(fields=['hospital', 'is_active']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.hospital.name} - {self.name}"
|
||||
|
||||
@ -512,7 +640,7 @@ class EscalationRule(UUIDModel, TimeStampedModel):
|
||||
class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Configurable thresholds for complaint-related triggers.
|
||||
|
||||
|
||||
Defines when to trigger actions based on metrics (e.g., survey scores).
|
||||
"""
|
||||
hospital = models.ForeignKey(
|
||||
@ -520,7 +648,7 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='complaint_thresholds'
|
||||
)
|
||||
|
||||
|
||||
threshold_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
@ -530,11 +658,11 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||
],
|
||||
help_text="Type of threshold"
|
||||
)
|
||||
|
||||
|
||||
threshold_value = models.FloatField(
|
||||
help_text="Threshold value (e.g., 50 for 50% score)"
|
||||
)
|
||||
|
||||
|
||||
comparison_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=[
|
||||
@ -547,7 +675,7 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||
default='lt',
|
||||
help_text="How to compare against threshold"
|
||||
)
|
||||
|
||||
|
||||
action_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
@ -557,19 +685,19 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||
],
|
||||
help_text="Action to take when threshold is breached"
|
||||
)
|
||||
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['hospital', 'threshold_type']
|
||||
indexes = [
|
||||
models.Index(fields=['hospital', 'is_active']),
|
||||
models.Index(fields=['threshold_type', 'is_active']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.hospital.name} - {self.threshold_type} {self.comparison_operator} {self.threshold_value}"
|
||||
|
||||
|
||||
def check_threshold(self, value):
|
||||
"""Check if value breaches threshold"""
|
||||
if self.comparison_operator == 'lt':
|
||||
@ -588,7 +716,7 @@ class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||
class Inquiry(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Inquiry model for general questions/requests.
|
||||
|
||||
|
||||
Similar to complaints but for non-complaint inquiries.
|
||||
"""
|
||||
# Patient information
|
||||
@ -599,12 +727,12 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='inquiries'
|
||||
)
|
||||
|
||||
|
||||
# Contact information (if patient not in system)
|
||||
contact_name = models.CharField(max_length=200, blank=True)
|
||||
contact_phone = models.CharField(max_length=20, blank=True)
|
||||
contact_email = models.EmailField(blank=True)
|
||||
|
||||
|
||||
# Organization
|
||||
hospital = models.ForeignKey(
|
||||
'organizations.Hospital',
|
||||
@ -618,11 +746,11 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='inquiries'
|
||||
)
|
||||
|
||||
|
||||
# Inquiry details
|
||||
subject = models.CharField(max_length=500)
|
||||
message = models.TextField()
|
||||
|
||||
|
||||
# Category
|
||||
category = models.CharField(
|
||||
max_length=100,
|
||||
@ -634,7 +762,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
||||
('other', 'Other'),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# Status
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
@ -647,7 +775,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
||||
default='open',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
# Assignment
|
||||
assigned_to = models.ForeignKey(
|
||||
'accounts.User',
|
||||
@ -656,7 +784,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='assigned_inquiries'
|
||||
)
|
||||
|
||||
|
||||
# Response
|
||||
response = models.TextField(blank=True)
|
||||
responded_at = models.DateTimeField(null=True, blank=True)
|
||||
@ -667,7 +795,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='responded_inquiries'
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name_plural = 'Inquiries'
|
||||
@ -675,7 +803,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
||||
models.Index(fields=['status', '-created_at']),
|
||||
models.Index(fields=['hospital', 'status']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.subject} ({self.status})"
|
||||
|
||||
|
||||
@ -18,27 +18,32 @@ logger = logging.getLogger(__name__)
|
||||
def handle_complaint_created(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Handle complaint creation.
|
||||
|
||||
|
||||
Triggers:
|
||||
- AI-powered severity and priority analysis
|
||||
- Create PX Action if hospital config requires it
|
||||
- Send notification to assigned user/department
|
||||
"""
|
||||
if created:
|
||||
# Import here to avoid circular imports
|
||||
from apps.complaints.tasks import (
|
||||
analyze_complaint_with_ai,
|
||||
create_action_from_complaint,
|
||||
send_complaint_notification,
|
||||
)
|
||||
|
||||
|
||||
# Trigger AI analysis (determines severity and priority)
|
||||
analyze_complaint_with_ai.delay(str(instance.id))
|
||||
|
||||
# Trigger PX Action creation (if configured)
|
||||
create_action_from_complaint.delay(str(instance.id))
|
||||
|
||||
|
||||
# Send notification
|
||||
send_complaint_notification.delay(
|
||||
complaint_id=str(instance.id),
|
||||
event_type='created'
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Handle survey completion.
|
||||
|
||||
|
||||
Checks if this is a complaint resolution survey and if score is below threshold.
|
||||
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
|
||||
if instance.metadata.get('complaint_id'):
|
||||
from apps.complaints.tasks import check_resolution_survey_threshold
|
||||
|
||||
|
||||
check_resolution_survey_threshold.delay(
|
||||
survey_instance_id=str(instance.id),
|
||||
complaint_id=instance.metadata['complaint_id']
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Resolution survey completed for complaint {instance.metadata['complaint_id']}: "
|
||||
f"Score = {instance.total_score}"
|
||||
|
||||
@ -6,6 +6,7 @@ This module contains tasks for:
|
||||
- Sending SLA reminders
|
||||
- Triggering resolution satisfaction surveys
|
||||
- Creating PX actions from complaints
|
||||
- AI-powered complaint analysis
|
||||
"""
|
||||
import logging
|
||||
|
||||
@ -21,21 +22,21 @@ logger = logging.getLogger(__name__)
|
||||
def check_overdue_complaints():
|
||||
"""
|
||||
Periodic task to check for overdue complaints.
|
||||
|
||||
|
||||
Runs every 15 minutes (configured in config/celery.py).
|
||||
Updates is_overdue flag for complaints past their SLA deadline.
|
||||
Triggers automatic escalation based on escalation rules.
|
||||
"""
|
||||
from apps.complaints.models import Complaint, ComplaintStatus
|
||||
|
||||
|
||||
# Get active complaints (not closed or cancelled)
|
||||
active_complaints = Complaint.objects.filter(
|
||||
status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED]
|
||||
).select_related('hospital', 'patient', 'department')
|
||||
|
||||
|
||||
overdue_count = 0
|
||||
escalated_count = 0
|
||||
|
||||
|
||||
for complaint in active_complaints:
|
||||
if complaint.check_overdue():
|
||||
overdue_count += 1
|
||||
@ -43,15 +44,15 @@ def check_overdue_complaints():
|
||||
f"Complaint {complaint.id} is overdue: {complaint.title} "
|
||||
f"(due: {complaint.due_at})"
|
||||
)
|
||||
|
||||
|
||||
# Trigger automatic escalation
|
||||
result = escalate_complaint_auto.delay(str(complaint.id))
|
||||
if result:
|
||||
escalated_count += 1
|
||||
|
||||
|
||||
if overdue_count > 0:
|
||||
logger.info(f"Found {overdue_count} overdue complaints, triggered {escalated_count} escalations")
|
||||
|
||||
|
||||
return {
|
||||
'overdue_count': overdue_count,
|
||||
'escalated_count': escalated_count
|
||||
@ -62,29 +63,29 @@ def check_overdue_complaints():
|
||||
def send_complaint_resolution_survey(complaint_id):
|
||||
"""
|
||||
Send resolution satisfaction survey when complaint is closed.
|
||||
|
||||
|
||||
This task is triggered when a complaint status changes to CLOSED.
|
||||
|
||||
|
||||
Args:
|
||||
complaint_id: UUID of the Complaint
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Result with survey_instance_id
|
||||
"""
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.core.services import create_audit_log
|
||||
from apps.surveys.models import SurveyInstance, SurveyTemplate
|
||||
|
||||
|
||||
try:
|
||||
complaint = Complaint.objects.select_related(
|
||||
'patient', 'hospital'
|
||||
).get(id=complaint_id)
|
||||
|
||||
|
||||
# Check if survey already sent
|
||||
if complaint.resolution_survey:
|
||||
logger.info(f"Resolution survey already sent for complaint {complaint_id}")
|
||||
return {'status': 'skipped', 'reason': 'already_sent'}
|
||||
|
||||
|
||||
# Get resolution satisfaction survey template
|
||||
try:
|
||||
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}"
|
||||
)
|
||||
return {'status': 'skipped', 'reason': 'no_template'}
|
||||
|
||||
|
||||
# Create survey instance
|
||||
with transaction.atomic():
|
||||
survey_instance = SurveyInstance.objects.create(
|
||||
@ -112,24 +113,24 @@ def send_complaint_resolution_survey(complaint_id):
|
||||
'complaint_title': complaint.title
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Link survey to complaint
|
||||
complaint.resolution_survey = survey_instance
|
||||
complaint.resolution_survey_sent_at = timezone.now()
|
||||
complaint.save(update_fields=['resolution_survey', 'resolution_survey_sent_at'])
|
||||
|
||||
|
||||
# Send survey
|
||||
from apps.notifications.services import NotificationService
|
||||
notification_log = NotificationService.send_survey_invitation(
|
||||
survey_instance=survey_instance,
|
||||
language='en' # TODO: Get from patient preference
|
||||
)
|
||||
|
||||
|
||||
# Update survey status
|
||||
survey_instance.status = 'active'
|
||||
survey_instance.sent_at = timezone.now()
|
||||
survey_instance.save(update_fields=['status', 'sent_at'])
|
||||
|
||||
|
||||
# Log audit event
|
||||
create_audit_log(
|
||||
event_type='survey_sent',
|
||||
@ -140,22 +141,22 @@ def send_complaint_resolution_survey(complaint_id):
|
||||
'survey_template': survey_template.name
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Resolution satisfaction survey sent for complaint {complaint.id}"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
'status': 'sent',
|
||||
'survey_instance_id': str(survey_instance.id),
|
||||
'notification_log_id': str(notification_log.id)
|
||||
}
|
||||
|
||||
|
||||
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 sending resolution survey: {str(e)}"
|
||||
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):
|
||||
"""
|
||||
Check if resolution survey score breaches threshold and create PX Action if needed.
|
||||
|
||||
|
||||
This task is triggered when a complaint resolution survey is completed.
|
||||
|
||||
|
||||
Args:
|
||||
survey_instance_id: UUID of the SurveyInstance
|
||||
complaint_id: UUID of the Complaint
|
||||
|
||||
|
||||
Returns:
|
||||
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.px_action_center.models import PXAction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
try:
|
||||
survey = SurveyInstance.objects.get(id=survey_instance_id)
|
||||
complaint = Complaint.objects.select_related('hospital', 'patient').get(id=complaint_id)
|
||||
|
||||
|
||||
# Get threshold for this hospital
|
||||
try:
|
||||
threshold = ComplaintThreshold.objects.get(
|
||||
@ -195,17 +196,17 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
||||
except ComplaintThreshold.DoesNotExist:
|
||||
logger.info(f"No resolution survey threshold configured for hospital {complaint.hospital.name_en}")
|
||||
return {'status': 'no_threshold'}
|
||||
|
||||
|
||||
# Check if threshold is breached
|
||||
if threshold.check_threshold(survey.score):
|
||||
logger.warning(
|
||||
f"Resolution survey score {survey.score} breaches threshold {threshold.threshold_value} "
|
||||
f"for complaint {complaint_id}"
|
||||
)
|
||||
|
||||
|
||||
# Create PX Action
|
||||
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
||||
|
||||
|
||||
action = PXAction.objects.create(
|
||||
title=f"Low Resolution Satisfaction: {complaint.title[:100]}",
|
||||
description=(
|
||||
@ -227,7 +228,7 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
||||
'threshold_value': threshold.threshold_value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import create_audit_log
|
||||
create_audit_log(
|
||||
@ -240,9 +241,9 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
||||
'trigger': 'resolution_survey_threshold'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Created PX Action {action.id} from low resolution survey score")
|
||||
|
||||
|
||||
return {
|
||||
'status': 'action_created',
|
||||
'action_id': str(action.id),
|
||||
@ -252,7 +253,7 @@ def check_resolution_survey_threshold(survey_instance_id, complaint_id):
|
||||
else:
|
||||
logger.info(f"Resolution survey score {survey.score} is above threshold {threshold.threshold_value}")
|
||||
return {'status': 'threshold_not_breached', 'survey_score': survey.score}
|
||||
|
||||
|
||||
except SurveyInstance.DoesNotExist:
|
||||
error_msg = f"SurveyInstance {survey_instance_id} not found"
|
||||
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):
|
||||
"""
|
||||
Create PX Action from complaint (if configured).
|
||||
|
||||
|
||||
This task is triggered when a complaint is created,
|
||||
if the hospital configuration requires automatic action creation.
|
||||
|
||||
|
||||
Args:
|
||||
complaint_id: UUID of the Complaint
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Result with action_id
|
||||
"""
|
||||
@ -285,22 +286,30 @@ def create_action_from_complaint(complaint_id):
|
||||
from apps.organizations.models import Hospital
|
||||
from apps.px_action_center.models import PXAction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
try:
|
||||
complaint = Complaint.objects.select_related('hospital', 'patient', 'department').get(id=complaint_id)
|
||||
|
||||
|
||||
# Check if hospital has auto-create enabled
|
||||
# For now, we'll check metadata on hospital or use a simple rule
|
||||
# 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:
|
||||
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'}
|
||||
|
||||
|
||||
# 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
|
||||
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
||||
|
||||
|
||||
action = PXAction.objects.create(
|
||||
title=f"New Complaint: {complaint.title[:100]}",
|
||||
description=complaint.description[:500],
|
||||
@ -313,11 +322,12 @@ def create_action_from_complaint(complaint_id):
|
||||
object_id=complaint.id,
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'complaint_category': complaint.category,
|
||||
'complaint_category': category_name,
|
||||
'complaint_category_id': category_id,
|
||||
'complaint_severity': complaint.severity,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import create_audit_log
|
||||
create_audit_log(
|
||||
@ -329,14 +339,14 @@ def create_action_from_complaint(complaint_id):
|
||||
'trigger': 'complaint_creation'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Created PX Action {action.id} from complaint {complaint_id}")
|
||||
|
||||
|
||||
return {
|
||||
'status': 'action_created',
|
||||
'action_id': str(action.id)
|
||||
}
|
||||
|
||||
|
||||
except Complaint.DoesNotExist:
|
||||
error_msg = f"Complaint {complaint_id} not found"
|
||||
logger.error(error_msg)
|
||||
@ -351,63 +361,63 @@ def create_action_from_complaint(complaint_id):
|
||||
def escalate_complaint_auto(complaint_id):
|
||||
"""
|
||||
Automatically escalate complaint based on escalation rules.
|
||||
|
||||
|
||||
This task is triggered when a complaint becomes overdue.
|
||||
It finds matching escalation rules and reassigns the complaint.
|
||||
|
||||
|
||||
Args:
|
||||
complaint_id: UUID of the Complaint
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Result with escalation status
|
||||
"""
|
||||
from apps.complaints.models import Complaint, ComplaintUpdate, EscalationRule
|
||||
from apps.accounts.models import User
|
||||
|
||||
|
||||
try:
|
||||
complaint = Complaint.objects.select_related(
|
||||
'hospital', 'department', 'assigned_to'
|
||||
).get(id=complaint_id)
|
||||
|
||||
|
||||
# Calculate hours overdue
|
||||
hours_overdue = (timezone.now() - complaint.due_at).total_seconds() / 3600
|
||||
|
||||
|
||||
# Get applicable escalation rules for this hospital
|
||||
rules = EscalationRule.objects.filter(
|
||||
hospital=complaint.hospital,
|
||||
is_active=True,
|
||||
trigger_on_overdue=True
|
||||
).order_by('order')
|
||||
|
||||
|
||||
# Filter rules by severity and priority if specified
|
||||
if complaint.severity:
|
||||
rules = rules.filter(
|
||||
Q(severity_filter='') | Q(severity_filter=complaint.severity)
|
||||
)
|
||||
|
||||
|
||||
if complaint.priority:
|
||||
rules = rules.filter(
|
||||
Q(priority_filter='') | Q(priority_filter=complaint.priority)
|
||||
)
|
||||
|
||||
|
||||
# Find first matching rule based on hours overdue
|
||||
matching_rule = None
|
||||
for rule in rules:
|
||||
if hours_overdue >= rule.trigger_hours_overdue:
|
||||
matching_rule = rule
|
||||
break
|
||||
|
||||
|
||||
if not matching_rule:
|
||||
logger.info(f"No matching escalation rule found for complaint {complaint_id}")
|
||||
return {'status': 'no_matching_rule'}
|
||||
|
||||
|
||||
# Determine escalation target
|
||||
escalation_target = None
|
||||
|
||||
|
||||
if matching_rule.escalate_to_role == 'department_manager':
|
||||
if complaint.department and complaint.department.manager:
|
||||
escalation_target = complaint.department.manager
|
||||
|
||||
|
||||
elif matching_rule.escalate_to_role == 'hospital_admin':
|
||||
# Find hospital admin for this hospital
|
||||
escalation_target = User.objects.filter(
|
||||
@ -415,30 +425,30 @@ def escalate_complaint_auto(complaint_id):
|
||||
groups__name='Hospital Admin',
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
|
||||
elif matching_rule.escalate_to_role == 'px_admin':
|
||||
# Find PX admin
|
||||
escalation_target = User.objects.filter(
|
||||
groups__name='PX Admin',
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
|
||||
elif matching_rule.escalate_to_role == 'specific_user':
|
||||
escalation_target = matching_rule.escalate_to_user
|
||||
|
||||
|
||||
if not escalation_target:
|
||||
logger.warning(
|
||||
f"Could not find escalation target for rule {matching_rule.name} "
|
||||
f"on complaint {complaint_id}"
|
||||
)
|
||||
return {'status': 'no_target_found', 'rule': matching_rule.name}
|
||||
|
||||
|
||||
# Perform escalation
|
||||
old_assignee = complaint.assigned_to
|
||||
complaint.assigned_to = escalation_target
|
||||
complaint.escalated_at = timezone.now()
|
||||
complaint.save(update_fields=['assigned_to', 'escalated_at'])
|
||||
|
||||
|
||||
# Create update
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
@ -457,13 +467,13 @@ def escalate_complaint_auto(complaint_id):
|
||||
'new_assignee_id': str(escalation_target.id)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Send notifications
|
||||
send_complaint_notification.delay(
|
||||
complaint_id=str(complaint.id),
|
||||
event_type='escalated'
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import create_audit_log
|
||||
create_audit_log(
|
||||
@ -476,19 +486,19 @@ def escalate_complaint_auto(complaint_id):
|
||||
'escalated_to': escalation_target.get_full_name()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Escalated complaint {complaint_id} to {escalation_target.get_full_name()} "
|
||||
f"using rule '{matching_rule.name}'"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
'status': 'escalated',
|
||||
'rule': matching_rule.name,
|
||||
'escalated_to': escalation_target.get_full_name(),
|
||||
'hours_overdue': round(hours_overdue, 2)
|
||||
}
|
||||
|
||||
|
||||
except Complaint.DoesNotExist:
|
||||
error_msg = f"Complaint {complaint_id} not found"
|
||||
logger.error(error_msg)
|
||||
@ -499,56 +509,232 @@ def escalate_complaint_auto(complaint_id):
|
||||
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
|
||||
def send_complaint_notification(complaint_id, event_type):
|
||||
"""
|
||||
Send notification for complaint events.
|
||||
|
||||
|
||||
Args:
|
||||
complaint_id: UUID of the Complaint
|
||||
event_type: Type of event (created, assigned, overdue, escalated, resolved, closed)
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Result with notification status
|
||||
"""
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
|
||||
try:
|
||||
complaint = Complaint.objects.select_related(
|
||||
'hospital', 'patient', 'assigned_to', 'department'
|
||||
).get(id=complaint_id)
|
||||
|
||||
|
||||
# Determine recipients based on event type
|
||||
recipients = []
|
||||
|
||||
|
||||
if event_type == 'created':
|
||||
# Notify assigned user or department manager
|
||||
if complaint.assigned_to:
|
||||
recipients.append(complaint.assigned_to)
|
||||
elif complaint.department and complaint.department.manager:
|
||||
recipients.append(complaint.department.manager)
|
||||
|
||||
|
||||
elif event_type == 'assigned':
|
||||
# Notify the assignee
|
||||
if complaint.assigned_to:
|
||||
recipients.append(complaint.assigned_to)
|
||||
|
||||
|
||||
elif event_type in ['overdue', 'escalated']:
|
||||
# Notify assignee and their manager
|
||||
if complaint.assigned_to:
|
||||
recipients.append(complaint.assigned_to)
|
||||
if complaint.department and complaint.department.manager:
|
||||
recipients.append(complaint.department.manager)
|
||||
|
||||
|
||||
elif event_type == 'resolved':
|
||||
# Notify patient
|
||||
recipients.append(complaint.patient)
|
||||
|
||||
|
||||
elif event_type == 'closed':
|
||||
# Notify patient
|
||||
recipients.append(complaint.patient)
|
||||
|
||||
|
||||
# Send notifications
|
||||
notification_count = 0
|
||||
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")
|
||||
except Exception as 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}")
|
||||
|
||||
|
||||
return {
|
||||
'status': 'sent',
|
||||
'notification_count': notification_count,
|
||||
'event_type': event_type
|
||||
}
|
||||
|
||||
|
||||
except Complaint.DoesNotExist:
|
||||
error_msg = f"Complaint {complaint_id} not found"
|
||||
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>/add-note/', ui_views.complaint_add_note, name='complaint_add_note'),
|
||||
path('<uuid:pk>/escalate/', ui_views.complaint_escalate, name='complaint_escalate'),
|
||||
|
||||
|
||||
# Export Views
|
||||
path('export/csv/', ui_views.complaint_export_csv, name='complaint_export_csv'),
|
||||
path('export/excel/', ui_views.complaint_export_excel, name='complaint_export_excel'),
|
||||
|
||||
|
||||
# Bulk Actions
|
||||
path('bulk/assign/', ui_views.complaint_bulk_assign, name='complaint_bulk_assign'),
|
||||
path('bulk/status/', ui_views.complaint_bulk_status, name='complaint_bulk_status'),
|
||||
path('bulk/escalate/', ui_views.complaint_bulk_escalate, name='complaint_bulk_escalate'),
|
||||
|
||||
|
||||
# Inquiries UI Views
|
||||
path('inquiries/', ui_views.inquiry_list, name='inquiry_list'),
|
||||
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>/add-note/', ui_views.inquiry_add_note, name='inquiry_add_note'),
|
||||
path('inquiries/<uuid:pk>/respond/', ui_views.inquiry_respond, name='inquiry_respond'),
|
||||
|
||||
|
||||
# Analytics
|
||||
path('analytics/', ui_views.complaints_analytics, name='complaints_analytics'),
|
||||
|
||||
|
||||
# AJAX Helpers
|
||||
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'),
|
||||
|
||||
|
||||
# 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
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@ -22,7 +22,7 @@ from .serializers import (
|
||||
class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Complaints with workflow actions.
|
||||
|
||||
|
||||
Permissions:
|
||||
- All authenticated users can view complaints
|
||||
- PX Admins and Hospital Admins can create/manage complaints
|
||||
@ -32,49 +32,49 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
filterset_fields = [
|
||||
'status', 'severity', 'priority', 'category', 'source',
|
||||
'hospital', 'department', 'physician', 'assigned_to',
|
||||
'is_overdue'
|
||||
'is_overdue', 'hospital__organization'
|
||||
]
|
||||
search_fields = ['title', 'description', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
||||
ordering_fields = ['created_at', 'due_at', 'severity']
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use simplified serializer for list view"""
|
||||
if self.action == 'list':
|
||||
return ComplaintListSerializer
|
||||
return ComplaintSerializer
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter complaints based on user role"""
|
||||
queryset = super().get_queryset().select_related(
|
||||
'patient', 'hospital', 'department', 'physician',
|
||||
'assigned_to', 'resolved_by', 'closed_by'
|
||||
).prefetch_related('attachments', 'updates')
|
||||
|
||||
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all complaints
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see complaints for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Department Managers see complaints for their department
|
||||
if user.is_department_manager() and user.department:
|
||||
return queryset.filter(department=user.department)
|
||||
|
||||
|
||||
# Others see complaints for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Log complaint creation and trigger resolution satisfaction survey"""
|
||||
complaint = serializer.save()
|
||||
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='complaint_created',
|
||||
description=f"Complaint created: {complaint.title}",
|
||||
@ -86,30 +86,30 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
'patient_mrn': complaint.patient.mrn
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# TODO: Optionally create PX Action (Phase 6)
|
||||
# from apps.complaints.tasks import create_action_from_complaint
|
||||
# create_action_from_complaint.delay(str(complaint.id))
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def assign(self, request, pk=None):
|
||||
"""Assign complaint to user"""
|
||||
complaint = self.get_object()
|
||||
user_id = request.data.get('user_id')
|
||||
|
||||
|
||||
if not user_id:
|
||||
return Response(
|
||||
{'error': 'user_id is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
from apps.accounts.models import User
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
complaint.assigned_to = user
|
||||
complaint.assigned_at = timezone.now()
|
||||
complaint.save(update_fields=['assigned_to', 'assigned_at'])
|
||||
|
||||
|
||||
# Create update
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
@ -117,37 +117,37 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
message=f"Assigned to {user.get_full_name()}",
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='assignment',
|
||||
description=f"Complaint assigned to {user.get_full_name()}",
|
||||
request=request,
|
||||
content_object=complaint
|
||||
)
|
||||
|
||||
|
||||
return Response({'message': 'Complaint assigned successfully'})
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'User not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def change_status(self, request, pk=None):
|
||||
"""Change complaint status"""
|
||||
complaint = self.get_object()
|
||||
new_status = request.data.get('status')
|
||||
note = request.data.get('note', '')
|
||||
|
||||
|
||||
if not new_status:
|
||||
return Response(
|
||||
{'error': 'status is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
old_status = complaint.status
|
||||
complaint.status = new_status
|
||||
|
||||
|
||||
# Handle status-specific logic
|
||||
if new_status == 'resolved':
|
||||
complaint.resolved_at = timezone.now()
|
||||
@ -155,13 +155,13 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
elif new_status == 'closed':
|
||||
complaint.closed_at = timezone.now()
|
||||
complaint.closed_by = request.user
|
||||
|
||||
|
||||
# Trigger resolution satisfaction survey
|
||||
from apps.complaints.tasks import send_complaint_resolution_survey
|
||||
send_complaint_resolution_survey.delay(str(complaint.id))
|
||||
|
||||
|
||||
complaint.save()
|
||||
|
||||
|
||||
# Create update
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
@ -171,7 +171,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
old_status=old_status,
|
||||
new_status=new_status
|
||||
)
|
||||
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='status_change',
|
||||
description=f"Complaint status changed from {old_status} to {new_status}",
|
||||
@ -179,21 +179,21 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
content_object=complaint,
|
||||
metadata={'old_status': old_status, 'new_status': new_status}
|
||||
)
|
||||
|
||||
|
||||
return Response({'message': 'Status updated successfully'})
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def add_note(self, request, pk=None):
|
||||
"""Add note to complaint"""
|
||||
complaint = self.get_object()
|
||||
note = request.data.get('note')
|
||||
|
||||
|
||||
if not note:
|
||||
return Response(
|
||||
{'error': 'note is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
# Create update
|
||||
update = ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
@ -201,7 +201,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
message=note,
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
|
||||
serializer = ComplaintUpdateSerializer(update)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@ -213,21 +213,21 @@ class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['complaint']
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().select_related('complaint', 'uploaded_by')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# Filter based on complaint access
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(complaint__hospital=user.hospital)
|
||||
|
||||
|
||||
if user.hospital:
|
||||
return queryset.filter(complaint__hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
@ -236,53 +236,53 @@ class InquiryViewSet(viewsets.ModelViewSet):
|
||||
queryset = Inquiry.objects.all()
|
||||
serializer_class = InquirySerializer
|
||||
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']
|
||||
ordering_fields = ['created_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter inquiries based on user role"""
|
||||
queryset = super().get_queryset().select_related(
|
||||
'patient', 'hospital', 'department', 'assigned_to', 'responded_by'
|
||||
)
|
||||
|
||||
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all inquiries
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see inquiries for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Department Managers see inquiries for their department
|
||||
if user.is_department_manager() and user.department:
|
||||
return queryset.filter(department=user.department)
|
||||
|
||||
|
||||
# Others see inquiries for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def respond(self, request, pk=None):
|
||||
"""Respond to inquiry"""
|
||||
inquiry = self.get_object()
|
||||
response_text = request.data.get('response')
|
||||
|
||||
|
||||
if not response_text:
|
||||
return Response(
|
||||
{'error': 'response is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
inquiry.response = response_text
|
||||
inquiry.responded_at = timezone.now()
|
||||
inquiry.responded_by = request.user
|
||||
inquiry.status = 'resolved'
|
||||
inquiry.save()
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Provide counts for sidebar badges.
|
||||
|
||||
|
||||
Returns counts for:
|
||||
- Active complaints
|
||||
- Pending feedback
|
||||
@ -16,24 +16,34 @@ def sidebar_counts(request):
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return {}
|
||||
|
||||
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.feedback.models import Feedback
|
||||
from apps.px_action_center.models import PXAction
|
||||
|
||||
|
||||
user = request.user
|
||||
|
||||
# Filter based on user role
|
||||
|
||||
# Filter based on user role and tenant_hospital
|
||||
if user.is_px_admin():
|
||||
complaint_count = Complaint.objects.filter(
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
feedback_count = Feedback.objects.filter(
|
||||
status__in=['submitted', 'reviewed']
|
||||
).count()
|
||||
action_count = PXAction.objects.filter(
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
# PX Admins use their selected hospital from session
|
||||
hospital = getattr(request, 'tenant_hospital', None)
|
||||
if hospital:
|
||||
complaint_count = Complaint.objects.filter(
|
||||
hospital=hospital,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
feedback_count = Feedback.objects.filter(
|
||||
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
|
||||
from apps.accounts.models import User
|
||||
provisional_user_count = User.objects.filter(
|
||||
@ -58,11 +68,29 @@ def sidebar_counts(request):
|
||||
complaint_count = 0
|
||||
feedback_count = 0
|
||||
action_count = 0
|
||||
provisional_user_count = 0
|
||||
|
||||
|
||||
return {
|
||||
'complaint_count': complaint_count,
|
||||
'feedback_count': feedback_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,
|
||||
}
|
||||
|
||||
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 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'
|
||||
)
|
||||
description = models.TextField()
|
||||
|
||||
|
||||
# Generic foreign key to link to any model
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
object_id = models.UUIDField(null=True, blank=True)
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
|
||||
# Additional metadata
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
@ -141,3 +141,23 @@ class SeverityChoices(BaseChoices):
|
||||
MEDIUM = 'medium', 'Medium'
|
||||
HIGH = 'high', 'High'
|
||||
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 .views import health_check
|
||||
from .views import health_check, select_hospital, no_hospital_assigned
|
||||
from . import config_views
|
||||
|
||||
app_name = 'core'
|
||||
|
||||
urlpatterns = [
|
||||
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)
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
"""
|
||||
Core views - Health check and utility views
|
||||
"""
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import connection
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
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
|
||||
@ -41,3 +43,50 @@ def health_check(request):
|
||||
|
||||
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.db.models import Avg, Count, Q
|
||||
from django.shortcuts import redirect
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -14,7 +15,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
"""
|
||||
PX Command Center Dashboard - Real-time control panel.
|
||||
|
||||
|
||||
Shows:
|
||||
- Top KPI cards (complaints, actions, surveys, etc.)
|
||||
- Charts (trends, satisfaction, leaderboards)
|
||||
@ -22,11 +23,19 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
- Filters (date range, hospital, department)
|
||||
"""
|
||||
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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# Import models
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.px_action_center.models import PXAction
|
||||
@ -35,21 +44,23 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
from apps.callcenter.models import CallCenterInteraction
|
||||
from apps.integrations.models import InboundEvent
|
||||
from apps.physicians.models import PhysicianMonthlyRating
|
||||
from apps.organizations.models import Physician
|
||||
|
||||
from apps.organizations.models import Staff
|
||||
|
||||
# Date filters
|
||||
now = timezone.now()
|
||||
last_24h = now - timedelta(hours=24)
|
||||
last_7d = now - timedelta(days=7)
|
||||
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():
|
||||
complaints_qs = Complaint.objects.all()
|
||||
actions_qs = PXAction.objects.all()
|
||||
surveys_qs = SurveyInstance.objects.all()
|
||||
social_qs = SocialMention.objects.all()
|
||||
calls_qs = CallCenterInteraction.objects.all()
|
||||
# PX Admins use their selected hospital from session
|
||||
hospital = self.request.tenant_hospital
|
||||
complaints_qs = Complaint.objects.filter(hospital=hospital) if hospital else Complaint.objects.none()
|
||||
actions_qs = PXAction.objects.filter(hospital=hospital) if hospital else PXAction.objects.none()
|
||||
surveys_qs = SurveyInstance.objects.all() # Surveys can be viewed across hospitals
|
||||
social_qs = 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:
|
||||
complaints_qs = Complaint.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()
|
||||
social_qs = SocialMention.objects.none()
|
||||
calls_qs = CallCenterInteraction.objects.none()
|
||||
|
||||
|
||||
# Top KPI Stats
|
||||
context['stats'] = [
|
||||
{
|
||||
@ -120,54 +131,58 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
'color': 'success'
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Latest high severity complaints
|
||||
context['latest_complaints'] = complaints_qs.filter(
|
||||
severity__in=['high', 'critical']
|
||||
).select_related('patient', 'hospital', 'department').order_by('-created_at')[:5]
|
||||
|
||||
|
||||
# Latest escalated actions
|
||||
context['latest_actions'] = actions_qs.filter(
|
||||
escalation_level__gt=0
|
||||
).select_related('hospital', 'assigned_to').order_by('-escalated_at')[:5]
|
||||
|
||||
|
||||
# Latest integration events
|
||||
context['latest_events'] = InboundEvent.objects.filter(
|
||||
status='processed'
|
||||
).select_related().order_by('-processed_at')[:10]
|
||||
|
||||
# Physician ratings data
|
||||
|
||||
# Staff ratings data
|
||||
current_month_ratings = PhysicianMonthlyRating.objects.filter(
|
||||
year=now.year,
|
||||
month=now.month
|
||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
||||
|
||||
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||
|
||||
# Filter by user role
|
||||
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:
|
||||
current_month_ratings = current_month_ratings.filter(physician__department=user.department)
|
||||
|
||||
# Top 5 physicians this month
|
||||
current_month_ratings = current_month_ratings.filter(staff__department=user.department)
|
||||
|
||||
# Top 5 staff this month
|
||||
context['top_physicians'] = current_month_ratings.order_by('-average_rating')[:5]
|
||||
|
||||
# Physician stats
|
||||
|
||||
# Staff stats
|
||||
physician_stats = current_month_ratings.aggregate(
|
||||
total_physicians=Count('id'),
|
||||
avg_rating=Avg('average_rating'),
|
||||
total_surveys=Count('total_surveys')
|
||||
)
|
||||
context['physician_stats'] = physician_stats
|
||||
|
||||
|
||||
# Chart data (simplified for now)
|
||||
import json
|
||||
context['chart_data'] = {
|
||||
'complaints_trend': json.dumps(self.get_complaints_trend(complaints_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
|
||||
|
||||
|
||||
def get_complaints_trend(self, queryset, start_date):
|
||||
"""Get complaints trend data for chart"""
|
||||
# Group by day for last 30 days
|
||||
@ -182,7 +197,7 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
'count': count
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
def get_survey_satisfaction(self, queryset, start_date):
|
||||
"""Get survey satisfaction averages"""
|
||||
return queryset.filter(
|
||||
|
||||
@ -3,14 +3,14 @@ Feedback forms - Forms for feedback management
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
class FeedbackForm(forms.ModelForm):
|
||||
"""Form for creating and editing feedback"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Feedback
|
||||
fields = [
|
||||
@ -21,7 +21,7 @@ class FeedbackForm(forms.ModelForm):
|
||||
'contact_phone',
|
||||
'hospital',
|
||||
'department',
|
||||
'physician',
|
||||
'staff',
|
||||
'feedback_type',
|
||||
'title',
|
||||
'message',
|
||||
@ -59,7 +59,7 @@ class FeedbackForm(forms.ModelForm):
|
||||
'department': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'physician': forms.Select(attrs={
|
||||
'staff': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'feedback_type': forms.Select(attrs={
|
||||
@ -99,11 +99,11 @@ class FeedbackForm(forms.ModelForm):
|
||||
'placeholder': 'Enter encounter ID (optional)'
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
# Filter hospitals based on user permissions
|
||||
if user:
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
@ -113,35 +113,35 @@ class FeedbackForm(forms.ModelForm):
|
||||
)
|
||||
else:
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(status='active')
|
||||
|
||||
|
||||
# Set initial hospital if user has one
|
||||
if user and user.hospital and not self.instance.pk:
|
||||
self.fields['hospital'].initial = user.hospital
|
||||
|
||||
|
||||
# Filter departments and physicians based on selected hospital
|
||||
if self.instance.pk and hasattr(self.instance, 'hospital') and self.instance.hospital_id:
|
||||
self.fields['department'].queryset = Department.objects.filter(
|
||||
hospital=self.instance.hospital,
|
||||
status='active'
|
||||
)
|
||||
self.fields['physician'].queryset = Physician.objects.filter(
|
||||
self.fields['staff'].queryset = Staff.objects.filter(
|
||||
hospital=self.instance.hospital,
|
||||
status='active'
|
||||
)
|
||||
else:
|
||||
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
|
||||
if self.data.get('is_anonymous'):
|
||||
self.fields['patient'].required = False
|
||||
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
is_anonymous = cleaned_data.get('is_anonymous')
|
||||
patient = cleaned_data.get('patient')
|
||||
contact_name = cleaned_data.get('contact_name')
|
||||
|
||||
|
||||
# Validate anonymous feedback
|
||||
if is_anonymous:
|
||||
if not contact_name:
|
||||
@ -153,18 +153,18 @@ class FeedbackForm(forms.ModelForm):
|
||||
raise forms.ValidationError(
|
||||
"Please select a patient or mark as anonymous."
|
||||
)
|
||||
|
||||
|
||||
# Validate rating
|
||||
rating = cleaned_data.get('rating')
|
||||
if rating is not None and (rating < 1 or rating > 5):
|
||||
raise forms.ValidationError("Rating must be between 1 and 5.")
|
||||
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class FeedbackResponseForm(forms.ModelForm):
|
||||
"""Form for adding responses to feedback"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = FeedbackResponse
|
||||
fields = ['response_type', 'message', 'is_internal']
|
||||
@ -187,7 +187,7 @@ class FeedbackResponseForm(forms.ModelForm):
|
||||
|
||||
class FeedbackFilterForm(forms.Form):
|
||||
"""Form for filtering feedback list"""
|
||||
|
||||
|
||||
search = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
@ -195,25 +195,25 @@ class FeedbackFilterForm(forms.Form):
|
||||
'placeholder': 'Search by title, message, patient...'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
feedback_type = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Types')] + list(FeedbackType.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
|
||||
status = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Statuses')] + list(FeedbackStatus.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
|
||||
category = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Categories')] + list(FeedbackCategory.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
|
||||
sentiment = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[
|
||||
@ -224,7 +224,7 @@ class FeedbackFilterForm(forms.Form):
|
||||
],
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
|
||||
priority = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[
|
||||
@ -236,21 +236,21 @@ class FeedbackFilterForm(forms.Form):
|
||||
],
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
|
||||
hospital = forms.ModelChoiceField(
|
||||
required=False,
|
||||
queryset=Hospital.objects.filter(status='active'),
|
||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||
empty_label='All Hospitals'
|
||||
)
|
||||
|
||||
|
||||
department = forms.ModelChoiceField(
|
||||
required=False,
|
||||
queryset=Department.objects.filter(status='active'),
|
||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||
empty_label='All Departments'
|
||||
)
|
||||
|
||||
|
||||
rating_min = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
@ -260,7 +260,7 @@ class FeedbackFilterForm(forms.Form):
|
||||
'placeholder': 'Min rating'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
rating_max = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
@ -270,7 +270,7 @@ class FeedbackFilterForm(forms.Form):
|
||||
'placeholder': 'Max rating'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
date_from = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={
|
||||
@ -278,7 +278,7 @@ class FeedbackFilterForm(forms.Form):
|
||||
'type': 'date'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
date_to = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={
|
||||
@ -286,12 +286,12 @@ class FeedbackFilterForm(forms.Form):
|
||||
'type': 'date'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
is_featured = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
|
||||
requires_follow_up = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
@ -300,7 +300,7 @@ class FeedbackFilterForm(forms.Form):
|
||||
|
||||
class FeedbackStatusChangeForm(forms.Form):
|
||||
"""Form for changing feedback status"""
|
||||
|
||||
|
||||
status = forms.ChoiceField(
|
||||
choices=FeedbackStatus.choices,
|
||||
widget=forms.Select(attrs={
|
||||
@ -308,7 +308,7 @@ class FeedbackStatusChangeForm(forms.Form):
|
||||
'required': True
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
note = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
@ -321,14 +321,14 @@ class FeedbackStatusChangeForm(forms.Form):
|
||||
|
||||
class FeedbackAssignForm(forms.Form):
|
||||
"""Form for assigning feedback to a user"""
|
||||
|
||||
|
||||
user_id = forms.UUIDField(
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
note = forms.CharField(
|
||||
required=False,
|
||||
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 uuid
|
||||
@ -16,6 +16,39 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FeedbackAttachment',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('file', models.FileField(upload_to='feedback/%Y/%m/%d/')),
|
||||
('filename', models.CharField(max_length=500)),
|
||||
('file_type', models.CharField(blank=True, max_length=100)),
|
||||
('file_size', models.IntegerField(help_text='File size in bytes')),
|
||||
('description', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FeedbackResponse',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('response_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Internal Note'), ('response', 'Response to Patient'), ('acknowledgment', 'Acknowledgment')], db_index=True, max_length=50)),
|
||||
('message', models.TextField()),
|
||||
('old_status', models.CharField(blank=True, max_length=20)),
|
||||
('new_status', models.CharField(blank=True, max_length=20)),
|
||||
('is_internal', models.BooleanField(default=False, help_text='Internal note (not visible to patient)')),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Feedback',
|
||||
fields=[
|
||||
@ -27,7 +60,7 @@ class Migration(migrations.Migration):
|
||||
('contact_email', models.EmailField(blank=True, max_length=254)),
|
||||
('contact_phone', models.CharField(blank=True, max_length=20)),
|
||||
('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)),
|
||||
('feedback_type', models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry')], 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)),
|
||||
('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)),
|
||||
@ -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')),
|
||||
('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')),
|
||||
('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={
|
||||
'verbose_name_plural': 'Feedback',
|
||||
'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):
|
||||
"""
|
||||
Feedback model for patient feedback, compliments, and suggestions.
|
||||
|
||||
|
||||
Workflow:
|
||||
1. SUBMITTED - Feedback received
|
||||
2. REVIEWED - Being reviewed by staff
|
||||
@ -71,20 +71,20 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
help_text="Patient who provided feedback (optional for anonymous feedback)"
|
||||
)
|
||||
|
||||
|
||||
# Anonymous feedback support
|
||||
is_anonymous = models.BooleanField(default=False)
|
||||
contact_name = models.CharField(max_length=200, blank=True)
|
||||
contact_email = models.EmailField(blank=True)
|
||||
contact_phone = models.CharField(max_length=20, blank=True)
|
||||
|
||||
|
||||
encounter_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Related encounter ID if applicable"
|
||||
)
|
||||
|
||||
|
||||
# Survey linkage (for satisfaction checks after negative surveys)
|
||||
related_survey = models.ForeignKey(
|
||||
'surveys.SurveyInstance',
|
||||
@ -94,7 +94,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
related_name='follow_up_feedbacks',
|
||||
help_text="Survey that triggered this satisfaction check feedback"
|
||||
)
|
||||
|
||||
|
||||
# Organization
|
||||
hospital = models.ForeignKey(
|
||||
'organizations.Hospital',
|
||||
@ -108,15 +108,15 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='feedbacks'
|
||||
)
|
||||
physician = models.ForeignKey(
|
||||
'organizations.Physician',
|
||||
staff = models.ForeignKey(
|
||||
'organizations.Staff',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='feedbacks',
|
||||
help_text="Physician being mentioned in feedback"
|
||||
help_text="Staff member being mentioned in feedback"
|
||||
)
|
||||
|
||||
|
||||
# Feedback details
|
||||
feedback_type = models.CharField(
|
||||
max_length=20,
|
||||
@ -124,10 +124,10 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
default=FeedbackType.GENERAL,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
title = models.CharField(max_length=500)
|
||||
message = models.TextField(help_text="Feedback message")
|
||||
|
||||
|
||||
# Classification
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
@ -135,14 +135,14 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
db_index=True
|
||||
)
|
||||
subcategory = models.CharField(max_length=100, blank=True)
|
||||
|
||||
|
||||
# Rating (1-5 stars)
|
||||
rating = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Rating from 1 to 5 stars"
|
||||
)
|
||||
|
||||
|
||||
# Priority
|
||||
priority = models.CharField(
|
||||
max_length=20,
|
||||
@ -150,7 +150,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
default=PriorityChoices.MEDIUM,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
# Sentiment analysis
|
||||
sentiment = models.CharField(
|
||||
max_length=20,
|
||||
@ -164,7 +164,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
help_text="Sentiment score from -1 (negative) to 1 (positive)"
|
||||
)
|
||||
|
||||
|
||||
# Status and workflow
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
@ -172,7 +172,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
default=FeedbackStatus.SUBMITTED,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
# Assignment
|
||||
assigned_to = models.ForeignKey(
|
||||
'accounts.User',
|
||||
@ -182,7 +182,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
related_name='assigned_feedbacks'
|
||||
)
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
# Review tracking
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_by = models.ForeignKey(
|
||||
@ -192,7 +192,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='reviewed_feedbacks'
|
||||
)
|
||||
|
||||
|
||||
# Acknowledgment
|
||||
acknowledged_at = models.DateTimeField(null=True, blank=True)
|
||||
acknowledged_by = models.ForeignKey(
|
||||
@ -202,7 +202,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='acknowledged_feedbacks'
|
||||
)
|
||||
|
||||
|
||||
# Closure
|
||||
closed_at = models.DateTimeField(null=True, blank=True)
|
||||
closed_by = models.ForeignKey(
|
||||
@ -212,7 +212,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='closed_feedbacks'
|
||||
)
|
||||
|
||||
|
||||
# Flags
|
||||
is_featured = models.BooleanField(
|
||||
default=False,
|
||||
@ -223,7 +223,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
help_text="Make this feedback public"
|
||||
)
|
||||
requires_follow_up = models.BooleanField(default=False)
|
||||
|
||||
|
||||
# Metadata
|
||||
source = models.CharField(
|
||||
max_length=50,
|
||||
@ -231,7 +231,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
help_text="Source of feedback (web, mobile, kiosk, etc.)"
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
# Soft delete
|
||||
is_deleted = models.BooleanField(default=False, db_index=True)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
@ -242,7 +242,7 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='deleted_feedbacks'
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
@ -253,18 +253,18 @@ class Feedback(UUIDModel, TimeStampedModel):
|
||||
models.Index(fields=['is_deleted', '-created_at']),
|
||||
]
|
||||
verbose_name_plural = 'Feedback'
|
||||
|
||||
|
||||
def __str__(self):
|
||||
if self.patient:
|
||||
return f"{self.title} - {self.patient.get_full_name()} ({self.feedback_type})"
|
||||
return f"{self.title} - Anonymous ({self.feedback_type})"
|
||||
|
||||
|
||||
def get_contact_name(self):
|
||||
"""Get contact name (patient or anonymous)"""
|
||||
if self.patient:
|
||||
return self.patient.get_full_name()
|
||||
return self.contact_name or "Anonymous"
|
||||
|
||||
|
||||
def soft_delete(self, user=None):
|
||||
"""Soft delete feedback"""
|
||||
self.is_deleted = True
|
||||
@ -280,12 +280,12 @@ class FeedbackAttachment(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='attachments'
|
||||
)
|
||||
|
||||
|
||||
file = models.FileField(upload_to='feedback/%Y/%m/%d/')
|
||||
filename = models.CharField(max_length=500)
|
||||
file_type = models.CharField(max_length=100, blank=True)
|
||||
file_size = models.IntegerField(help_text="File size in bytes")
|
||||
|
||||
|
||||
uploaded_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -293,12 +293,12 @@ class FeedbackAttachment(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
related_name='feedback_attachments'
|
||||
)
|
||||
|
||||
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.feedback} - {self.filename}"
|
||||
|
||||
@ -306,7 +306,7 @@ class FeedbackAttachment(UUIDModel, TimeStampedModel):
|
||||
class FeedbackResponse(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Feedback response/timeline entry.
|
||||
|
||||
|
||||
Tracks all responses, status changes, and communications.
|
||||
"""
|
||||
feedback = models.ForeignKey(
|
||||
@ -314,7 +314,7 @@ class FeedbackResponse(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='responses'
|
||||
)
|
||||
|
||||
|
||||
# Response details
|
||||
response_type = models.CharField(
|
||||
max_length=50,
|
||||
@ -327,9 +327,9 @@ class FeedbackResponse(UUIDModel, TimeStampedModel):
|
||||
],
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
message = models.TextField()
|
||||
|
||||
|
||||
# User who made the response
|
||||
created_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
@ -337,25 +337,25 @@ class FeedbackResponse(UUIDModel, TimeStampedModel):
|
||||
null=True,
|
||||
related_name='feedback_responses'
|
||||
)
|
||||
|
||||
|
||||
# Status change tracking
|
||||
old_status = models.CharField(max_length=20, blank=True)
|
||||
new_status = models.CharField(max_length=20, blank=True)
|
||||
|
||||
|
||||
# Visibility
|
||||
is_internal = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Internal note (not visible to patient)"
|
||||
)
|
||||
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['feedback', '-created_at']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.feedback} - {self.response_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
@ -11,7 +11,7 @@ from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.accounts.models import User
|
||||
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 (
|
||||
Feedback,
|
||||
@ -32,7 +32,7 @@ from .forms import (
|
||||
def feedback_list(request):
|
||||
"""
|
||||
Feedback list view with advanced filters and pagination.
|
||||
|
||||
|
||||
Features:
|
||||
- Server-side pagination
|
||||
- Advanced filters (status, type, sentiment, category, hospital, etc.)
|
||||
@ -42,10 +42,10 @@ def feedback_list(request):
|
||||
"""
|
||||
# Base queryset with optimizations
|
||||
queryset = Feedback.objects.select_related(
|
||||
'patient', 'hospital', 'department', 'physician',
|
||||
'patient', 'hospital', 'department', 'staff',
|
||||
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
|
||||
).filter(is_deleted=False)
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if user.is_px_admin():
|
||||
@ -58,60 +58,60 @@ def feedback_list(request):
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
|
||||
# Apply filters from request
|
||||
feedback_type_filter = request.GET.get('feedback_type')
|
||||
if feedback_type_filter:
|
||||
queryset = queryset.filter(feedback_type=feedback_type_filter)
|
||||
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
category_filter = request.GET.get('category')
|
||||
if category_filter:
|
||||
queryset = queryset.filter(category=category_filter)
|
||||
|
||||
|
||||
sentiment_filter = request.GET.get('sentiment')
|
||||
if sentiment_filter:
|
||||
queryset = queryset.filter(sentiment=sentiment_filter)
|
||||
|
||||
|
||||
priority_filter = request.GET.get('priority')
|
||||
if priority_filter:
|
||||
queryset = queryset.filter(priority=priority_filter)
|
||||
|
||||
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
|
||||
department_filter = request.GET.get('department')
|
||||
if department_filter:
|
||||
queryset = queryset.filter(department_id=department_filter)
|
||||
|
||||
physician_filter = request.GET.get('physician')
|
||||
if physician_filter:
|
||||
queryset = queryset.filter(physician_id=physician_filter)
|
||||
|
||||
|
||||
staff_filter = request.GET.get('staff')
|
||||
if staff_filter:
|
||||
queryset = queryset.filter(staff_id=staff_filter)
|
||||
|
||||
assigned_to_filter = request.GET.get('assigned_to')
|
||||
if assigned_to_filter:
|
||||
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
|
||||
|
||||
|
||||
rating_min = request.GET.get('rating_min')
|
||||
if rating_min:
|
||||
queryset = queryset.filter(rating__gte=rating_min)
|
||||
|
||||
|
||||
rating_max = request.GET.get('rating_max')
|
||||
if rating_max:
|
||||
queryset = queryset.filter(rating__lte=rating_max)
|
||||
|
||||
|
||||
is_featured = request.GET.get('is_featured')
|
||||
if is_featured == 'true':
|
||||
queryset = queryset.filter(is_featured=True)
|
||||
|
||||
|
||||
requires_follow_up = request.GET.get('requires_follow_up')
|
||||
if requires_follow_up == 'true':
|
||||
queryset = queryset.filter(requires_follow_up=True)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
@ -123,40 +123,40 @@ def feedback_list(request):
|
||||
Q(patient__last_name__icontains=search_query) |
|
||||
Q(contact_name__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Date range filters
|
||||
date_from = request.GET.get('date_from')
|
||||
if date_from:
|
||||
queryset = queryset.filter(created_at__gte=date_from)
|
||||
|
||||
|
||||
date_to = request.GET.get('date_to')
|
||||
if date_to:
|
||||
queryset = queryset.filter(created_at__lte=date_to)
|
||||
|
||||
|
||||
# Ordering
|
||||
order_by = request.GET.get('order_by', '-created_at')
|
||||
queryset = queryset.order_by(order_by)
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
departments = departments.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Get assignable users
|
||||
assignable_users = User.objects.filter(is_active=True)
|
||||
if user.hospital:
|
||||
assignable_users = assignable_users.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
'total': queryset.count(),
|
||||
@ -169,7 +169,7 @@ def feedback_list(request):
|
||||
'positive': queryset.filter(sentiment='positive').count(),
|
||||
'negative': queryset.filter(sentiment='negative').count(),
|
||||
}
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'feedbacks': page_obj.object_list,
|
||||
@ -182,7 +182,7 @@ def feedback_list(request):
|
||||
'category_choices': FeedbackCategory.choices,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'feedback/feedback_list.html', context)
|
||||
|
||||
|
||||
@ -190,7 +190,7 @@ def feedback_list(request):
|
||||
def feedback_detail(request, pk):
|
||||
"""
|
||||
Feedback detail view with timeline, attachments, and actions.
|
||||
|
||||
|
||||
Features:
|
||||
- Full feedback details
|
||||
- Timeline of all responses
|
||||
@ -199,7 +199,7 @@ def feedback_detail(request, pk):
|
||||
"""
|
||||
feedback = get_object_or_404(
|
||||
Feedback.objects.select_related(
|
||||
'patient', 'hospital', 'department', 'physician',
|
||||
'patient', 'hospital', 'department', 'staff',
|
||||
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
|
||||
).prefetch_related(
|
||||
'attachments',
|
||||
@ -208,7 +208,7 @@ def feedback_detail(request, pk):
|
||||
pk=pk,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
|
||||
# Check access
|
||||
user = request.user
|
||||
if not user.is_px_admin():
|
||||
@ -221,18 +221,18 @@ def feedback_detail(request, pk):
|
||||
elif user.hospital and feedback.hospital != user.hospital:
|
||||
messages.error(request, "You don't have permission to view this feedback.")
|
||||
return redirect('feedback:feedback_list')
|
||||
|
||||
|
||||
# Get timeline (responses)
|
||||
timeline = feedback.responses.all().order_by('-created_at')
|
||||
|
||||
|
||||
# Get attachments
|
||||
attachments = feedback.attachments.all().order_by('-created_at')
|
||||
|
||||
|
||||
# Get assignable users
|
||||
assignable_users = User.objects.filter(is_active=True)
|
||||
if feedback.hospital:
|
||||
assignable_users = assignable_users.filter(hospital=feedback.hospital)
|
||||
|
||||
|
||||
context = {
|
||||
'feedback': feedback,
|
||||
'timeline': timeline,
|
||||
@ -241,7 +241,7 @@ def feedback_detail(request, pk):
|
||||
'status_choices': FeedbackStatus.choices,
|
||||
'can_edit': user.is_px_admin() or user.is_hospital_admin(),
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'feedback/feedback_detail.html', context)
|
||||
|
||||
|
||||
@ -254,13 +254,13 @@ def feedback_create(request):
|
||||
if form.is_valid():
|
||||
try:
|
||||
feedback = form.save(commit=False)
|
||||
|
||||
|
||||
# Set default sentiment if not set
|
||||
if not feedback.sentiment:
|
||||
feedback.sentiment = 'neutral'
|
||||
|
||||
|
||||
feedback.save()
|
||||
|
||||
|
||||
# Create initial response
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
@ -269,7 +269,7 @@ def feedback_create(request):
|
||||
created_by=request.user,
|
||||
is_internal=True
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='feedback_created',
|
||||
@ -282,28 +282,28 @@ def feedback_create(request):
|
||||
'rating': feedback.rating
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, f"Feedback #{feedback.id} created successfully.")
|
||||
return redirect('feedback:feedback_detail', pk=feedback.id)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating feedback: {str(e)}")
|
||||
else:
|
||||
messages.error(request, "Please correct the errors below.")
|
||||
else:
|
||||
form = FeedbackForm(user=request.user)
|
||||
|
||||
|
||||
# Get patients for selection
|
||||
patients = Patient.objects.filter(status='active')
|
||||
if request.user.hospital:
|
||||
patients = patients.filter(primary_hospital=request.user.hospital)
|
||||
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'patients': patients,
|
||||
'is_create': True,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'feedback/feedback_form.html', context)
|
||||
|
||||
|
||||
@ -312,19 +312,19 @@ def feedback_create(request):
|
||||
def feedback_update(request, pk):
|
||||
"""Update existing feedback"""
|
||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to edit feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
form = FeedbackForm(request.POST, instance=feedback, user=request.user)
|
||||
if form.is_valid():
|
||||
try:
|
||||
feedback = form.save()
|
||||
|
||||
|
||||
# Create update response
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
@ -333,7 +333,7 @@ def feedback_update(request, pk):
|
||||
created_by=request.user,
|
||||
is_internal=True
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='feedback_updated',
|
||||
@ -341,29 +341,29 @@ def feedback_update(request, pk):
|
||||
user=request.user,
|
||||
content_object=feedback
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, "Feedback updated successfully.")
|
||||
return redirect('feedback:feedback_detail', pk=feedback.id)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error updating feedback: {str(e)}")
|
||||
else:
|
||||
messages.error(request, "Please correct the errors below.")
|
||||
else:
|
||||
form = FeedbackForm(instance=feedback, user=request.user)
|
||||
|
||||
|
||||
# Get patients for selection
|
||||
patients = Patient.objects.filter(status='active')
|
||||
if request.user.hospital:
|
||||
patients = patients.filter(primary_hospital=request.user.hospital)
|
||||
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'feedback': feedback,
|
||||
'patients': patients,
|
||||
'is_create': False,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'feedback/feedback_form.html', context)
|
||||
|
||||
|
||||
@ -372,17 +372,17 @@ def feedback_update(request, pk):
|
||||
def feedback_delete(request, pk):
|
||||
"""Soft delete feedback"""
|
||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to delete feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
feedback.soft_delete(user=request.user)
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='feedback_deleted',
|
||||
@ -390,18 +390,18 @@ def feedback_delete(request, pk):
|
||||
user=request.user,
|
||||
content_object=feedback
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, "Feedback deleted successfully.")
|
||||
return redirect('feedback:feedback_list')
|
||||
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error deleting feedback: {str(e)}")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
context = {
|
||||
'feedback': feedback,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'feedback/feedback_delete_confirm.html', context)
|
||||
|
||||
|
||||
@ -410,31 +410,31 @@ def feedback_delete(request, pk):
|
||||
def feedback_assign(request, pk):
|
||||
"""Assign feedback to user"""
|
||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to assign feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
user_id = request.POST.get('user_id')
|
||||
note = request.POST.get('note', '')
|
||||
|
||||
|
||||
if not user_id:
|
||||
messages.error(request, "Please select a user to assign.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
try:
|
||||
assignee = User.objects.get(id=user_id)
|
||||
feedback.assigned_to = assignee
|
||||
feedback.assigned_at = timezone.now()
|
||||
feedback.save(update_fields=['assigned_to', 'assigned_at'])
|
||||
|
||||
|
||||
# Create response
|
||||
message = f"Assigned to {assignee.get_full_name()}"
|
||||
if note:
|
||||
message += f"\nNote: {note}"
|
||||
|
||||
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
response_type='assignment',
|
||||
@ -442,7 +442,7 @@ def feedback_assign(request, pk):
|
||||
created_by=request.user,
|
||||
is_internal=True
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='assignment',
|
||||
@ -450,12 +450,12 @@ def feedback_assign(request, pk):
|
||||
user=request.user,
|
||||
content_object=feedback
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, f"Feedback assigned to {assignee.get_full_name()}.")
|
||||
|
||||
|
||||
except User.DoesNotExist:
|
||||
messages.error(request, "User not found.")
|
||||
|
||||
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
@ -464,23 +464,23 @@ def feedback_assign(request, pk):
|
||||
def feedback_change_status(request, pk):
|
||||
"""Change feedback status"""
|
||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to change feedback status.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
new_status = request.POST.get('status')
|
||||
note = request.POST.get('note', '')
|
||||
|
||||
|
||||
if not new_status:
|
||||
messages.error(request, "Please select a status.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
old_status = feedback.status
|
||||
feedback.status = new_status
|
||||
|
||||
|
||||
# Handle status-specific logic
|
||||
if new_status == FeedbackStatus.REVIEWED:
|
||||
feedback.reviewed_at = timezone.now()
|
||||
@ -491,12 +491,12 @@ def feedback_change_status(request, pk):
|
||||
elif new_status == FeedbackStatus.CLOSED:
|
||||
feedback.closed_at = timezone.now()
|
||||
feedback.closed_by = request.user
|
||||
|
||||
|
||||
feedback.save()
|
||||
|
||||
|
||||
# Create response
|
||||
message = note or f"Status changed from {old_status} to {new_status}"
|
||||
|
||||
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
response_type='status_change',
|
||||
@ -506,7 +506,7 @@ def feedback_change_status(request, pk):
|
||||
new_status=new_status,
|
||||
is_internal=True
|
||||
)
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='status_change',
|
||||
@ -515,7 +515,7 @@ def feedback_change_status(request, pk):
|
||||
content_object=feedback,
|
||||
metadata={'old_status': old_status, 'new_status': new_status}
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, f"Feedback status changed to {new_status}.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
@ -525,15 +525,15 @@ def feedback_change_status(request, pk):
|
||||
def feedback_add_response(request, pk):
|
||||
"""Add response to feedback"""
|
||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||
|
||||
|
||||
response_type = request.POST.get('response_type', 'response')
|
||||
message = request.POST.get('message')
|
||||
is_internal = request.POST.get('is_internal') == 'on'
|
||||
|
||||
|
||||
if not message:
|
||||
messages.error(request, "Please enter a response message.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
# Create response
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
@ -542,7 +542,7 @@ def feedback_add_response(request, pk):
|
||||
created_by=request.user,
|
||||
is_internal=is_internal
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, "Response added successfully.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
@ -552,19 +552,19 @@ def feedback_add_response(request, pk):
|
||||
def feedback_toggle_featured(request, pk):
|
||||
"""Toggle featured status"""
|
||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to feature feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
feedback.is_featured = not feedback.is_featured
|
||||
feedback.save(update_fields=['is_featured'])
|
||||
|
||||
|
||||
status = "featured" if feedback.is_featured else "unfeatured"
|
||||
messages.success(request, f"Feedback {status} successfully.")
|
||||
|
||||
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
@ -573,17 +573,17 @@ def feedback_toggle_featured(request, pk):
|
||||
def feedback_toggle_follow_up(request, pk):
|
||||
"""Toggle follow-up required status"""
|
||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to modify feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
|
||||
|
||||
feedback.requires_follow_up = not feedback.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"
|
||||
messages.success(request, f"Feedback {status} successfully.")
|
||||
|
||||
|
||||
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 uuid
|
||||
|
||||
@ -19,32 +19,32 @@ logger = logging.getLogger('apps.integrations')
|
||||
def process_inbound_event(self, event_id):
|
||||
"""
|
||||
Process an inbound integration event.
|
||||
|
||||
|
||||
This is the core event processing task that:
|
||||
1. Finds the journey instance by encounter_id
|
||||
2. Finds the matching stage by trigger_event_code
|
||||
3. Completes the stage
|
||||
4. Creates survey instance if configured
|
||||
5. Logs audit events
|
||||
|
||||
|
||||
Args:
|
||||
event_id: UUID of the InboundEvent to process
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Processing result with status and details
|
||||
"""
|
||||
from apps.core.services import create_audit_log
|
||||
from apps.integrations.models import InboundEvent
|
||||
from apps.journeys.models import PatientJourneyInstance, PatientJourneyStageInstance, StageStatus
|
||||
from apps.organizations.models import Department, Physician
|
||||
|
||||
from apps.organizations.models import Department, Staff
|
||||
|
||||
try:
|
||||
# Get the event
|
||||
event = InboundEvent.objects.get(id=event_id)
|
||||
event.mark_processing()
|
||||
|
||||
|
||||
logger.info(f"Processing event {event.id}: {event.event_code} for encounter {event.encounter_id}")
|
||||
|
||||
|
||||
# Find journey instance by encounter_id
|
||||
try:
|
||||
journey_instance = PatientJourneyInstance.objects.select_related(
|
||||
@ -55,35 +55,35 @@ def process_inbound_event(self, event_id):
|
||||
logger.warning(error_msg)
|
||||
event.mark_ignored(error_msg)
|
||||
return {'status': 'ignored', 'reason': error_msg}
|
||||
|
||||
|
||||
# Find matching stage by trigger_event_code
|
||||
matching_stages = journey_instance.stage_instances.filter(
|
||||
stage_template__trigger_event_code=event.event_code,
|
||||
status__in=[StageStatus.PENDING, StageStatus.IN_PROGRESS]
|
||||
).select_related('stage_template')
|
||||
|
||||
|
||||
if not matching_stages.exists():
|
||||
error_msg = f"No pending stage found with trigger {event.event_code}"
|
||||
logger.warning(error_msg)
|
||||
event.mark_ignored(error_msg)
|
||||
return {'status': 'ignored', 'reason': error_msg}
|
||||
|
||||
|
||||
# Get the first matching stage
|
||||
stage_instance = matching_stages.first()
|
||||
|
||||
# Extract physician and department from event payload
|
||||
physician = None
|
||||
|
||||
# Extract staff and department from event payload
|
||||
staff = None
|
||||
department = None
|
||||
|
||||
|
||||
if event.physician_license:
|
||||
try:
|
||||
physician = Physician.objects.get(
|
||||
staff = Staff.objects.get(
|
||||
license_number=event.physician_license,
|
||||
hospital=journey_instance.hospital
|
||||
)
|
||||
except Physician.DoesNotExist:
|
||||
logger.warning(f"Physician not found: {event.physician_license}")
|
||||
|
||||
except Staff.DoesNotExist:
|
||||
logger.warning(f"Staff member not found with license: {event.physician_license}")
|
||||
|
||||
if event.department_code:
|
||||
try:
|
||||
department = Department.objects.get(
|
||||
@ -92,16 +92,16 @@ def process_inbound_event(self, event_id):
|
||||
)
|
||||
except Department.DoesNotExist:
|
||||
logger.warning(f"Department not found: {event.department_code}")
|
||||
|
||||
|
||||
# Complete the stage
|
||||
with transaction.atomic():
|
||||
success = stage_instance.complete(
|
||||
event=event,
|
||||
physician=physician,
|
||||
staff=staff,
|
||||
department=department,
|
||||
metadata=event.payload_json
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
# Log stage completion
|
||||
create_audit_log(
|
||||
@ -114,31 +114,31 @@ def process_inbound_event(self, event_id):
|
||||
'journey_type': journey_instance.journey_template.journey_type
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Check if survey should be sent
|
||||
if stage_instance.stage_template.auto_send_survey and stage_instance.stage_template.survey_template:
|
||||
# Queue survey creation task with delay
|
||||
from apps.surveys.tasks import create_and_send_survey
|
||||
delay_seconds = stage_instance.stage_template.survey_delay_hours * 3600
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Queuing survey for stage {stage_instance.stage_template.name} "
|
||||
f"(delay: {stage_instance.stage_template.survey_delay_hours}h)"
|
||||
)
|
||||
|
||||
|
||||
create_and_send_survey.apply_async(
|
||||
args=[str(stage_instance.id)],
|
||||
countdown=delay_seconds
|
||||
)
|
||||
|
||||
|
||||
# Mark event as processed
|
||||
event.mark_processed()
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Successfully processed event {event.id}: "
|
||||
f"Completed stage {stage_instance.stage_template.name}"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
'status': 'processed',
|
||||
'stage_completed': stage_instance.stage_template.name,
|
||||
@ -148,21 +148,21 @@ def process_inbound_event(self, event_id):
|
||||
error_msg = "Failed to complete stage"
|
||||
event.mark_failed(error_msg)
|
||||
return {'status': 'failed', 'reason': error_msg}
|
||||
|
||||
|
||||
except InboundEvent.DoesNotExist:
|
||||
error_msg = f"Event {event_id} not found"
|
||||
logger.error(error_msg)
|
||||
return {'status': 'error', 'reason': error_msg}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing event: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
|
||||
try:
|
||||
event.mark_failed(error_msg)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
# Retry the task
|
||||
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():
|
||||
"""
|
||||
Periodic task to process pending events.
|
||||
|
||||
|
||||
This task runs every minute (configured in config/celery.py)
|
||||
and processes all pending events.
|
||||
"""
|
||||
from apps.integrations.models import InboundEvent
|
||||
|
||||
|
||||
pending_events = InboundEvent.objects.filter(
|
||||
status='pending'
|
||||
).order_by('received_at')[:100] # Process max 100 at a time
|
||||
|
||||
|
||||
processed_count = 0
|
||||
|
||||
|
||||
for event in pending_events:
|
||||
# Queue individual event for processing
|
||||
process_inbound_event.delay(str(event.id))
|
||||
processed_count += 1
|
||||
|
||||
|
||||
if processed_count > 0:
|
||||
logger.info(f"Queued {processed_count} pending events for processing")
|
||||
|
||||
|
||||
return {'queued': processed_count}
|
||||
|
||||
@ -30,15 +30,15 @@ class PatientJourneyTemplateAdmin(admin.ModelAdmin):
|
||||
search_fields = ['name', 'name_ar', 'description']
|
||||
ordering = ['hospital', 'journey_type', 'name']
|
||||
inlines = [PatientJourneyStageTemplateInline]
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'name_ar', 'journey_type', 'description')}),
|
||||
('Configuration', {'fields': ('hospital', 'is_active', 'is_default')}),
|
||||
('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('hospital')
|
||||
@ -54,7 +54,7 @@ class PatientJourneyStageTemplateAdmin(admin.ModelAdmin):
|
||||
list_filter = ['journey_template__journey_type', 'auto_send_survey', 'is_optional', 'is_active']
|
||||
search_fields = ['name', 'name_ar', 'code', 'trigger_event_code']
|
||||
ordering = ['journey_template', 'order']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('journey_template', 'name', 'name_ar', 'code', 'order')}),
|
||||
('Event Trigger', {'fields': ('trigger_event_code',)}),
|
||||
@ -69,9 +69,9 @@ class PatientJourneyStageTemplateAdmin(admin.ModelAdmin):
|
||||
}),
|
||||
('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('journey_template', 'survey_template')
|
||||
@ -83,11 +83,11 @@ class PatientJourneyStageInstanceInline(admin.TabularInline):
|
||||
extra = 0
|
||||
fields = [
|
||||
'stage_template', 'status', 'completed_at',
|
||||
'physician', 'department', 'survey_instance'
|
||||
'staff', 'department', 'survey_instance'
|
||||
]
|
||||
readonly_fields = ['stage_template', 'completed_at', 'survey_instance']
|
||||
ordering = ['stage_template__order']
|
||||
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@ -103,7 +103,7 @@ class PatientJourneyInstanceAdmin(admin.ModelAdmin):
|
||||
search_fields = ['encounter_id', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
||||
ordering = ['-started_at']
|
||||
inlines = [PatientJourneyStageInstanceInline]
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('journey_template', 'patient', 'encounter_id')
|
||||
@ -119,15 +119,15 @@ class PatientJourneyInstanceAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['started_at', 'completed_at', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related(
|
||||
'journey_template', 'patient', 'hospital', 'department'
|
||||
).prefetch_related('stage_instances')
|
||||
|
||||
|
||||
def get_completion_percentage(self, obj):
|
||||
"""Display completion percentage"""
|
||||
return f"{obj.get_completion_percentage()}%"
|
||||
@ -139,7 +139,7 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
|
||||
"""Journey stage instance admin"""
|
||||
list_display = [
|
||||
'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']
|
||||
search_fields = [
|
||||
@ -148,13 +148,13 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
|
||||
'stage_template__name'
|
||||
]
|
||||
ordering = ['journey_instance', 'stage_template__order']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('journey_instance', 'stage_template', 'status')
|
||||
}),
|
||||
('Completion Details', {
|
||||
'fields': ('completed_at', 'completed_by_event', 'physician', 'department')
|
||||
'fields': ('completed_at', 'completed_by_event', 'staff', 'department')
|
||||
}),
|
||||
('Survey', {
|
||||
'fields': ('survey_instance', 'survey_sent_at')
|
||||
@ -164,15 +164,15 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['completed_at', 'completed_by_event', 'survey_sent_at', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related(
|
||||
'journey_instance',
|
||||
'stage_template',
|
||||
'physician',
|
||||
'staff',
|
||||
'department',
|
||||
'survey_instance',
|
||||
'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 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')),
|
||||
('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')),
|
||||
('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={
|
||||
'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
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -294,13 +294,13 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
|
||||
# Context from event
|
||||
physician = models.ForeignKey(
|
||||
'organizations.Physician',
|
||||
staff = models.ForeignKey(
|
||||
'organizations.Staff',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='journey_stages',
|
||||
help_text="Physician associated with this stage"
|
||||
help_text="Staff member associated with this stage"
|
||||
)
|
||||
department = models.ForeignKey(
|
||||
'organizations.Department',
|
||||
@ -344,15 +344,15 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
|
||||
"""Check if this stage can be completed"""
|
||||
return self.status in [StageStatus.PENDING, StageStatus.IN_PROGRESS]
|
||||
|
||||
def complete(self, event=None, physician=None, department=None, metadata=None):
|
||||
def complete(self, event=None, staff=None, department=None, metadata=None):
|
||||
"""
|
||||
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:
|
||||
1. Update status to COMPLETED
|
||||
2. Set completion timestamp
|
||||
3. Attach event, physician, department
|
||||
3. Attach event, staff, department
|
||||
4. Trigger survey creation if configured
|
||||
"""
|
||||
from django.utils import timezone
|
||||
@ -364,8 +364,8 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
|
||||
self.completed_at = timezone.now()
|
||||
self.completed_by_event = event
|
||||
|
||||
if physician:
|
||||
self.physician = physician
|
||||
if staff:
|
||||
self.staff = staff
|
||||
if department:
|
||||
self.department = department
|
||||
if metadata:
|
||||
|
||||
@ -27,7 +27,7 @@ from .serializers import (
|
||||
class PatientJourneyTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Journey Templates.
|
||||
|
||||
|
||||
Permissions:
|
||||
- PX Admins and Hospital Admins can manage templates
|
||||
- Others can view templates
|
||||
@ -35,35 +35,35 @@ class PatientJourneyTemplateViewSet(viewsets.ModelViewSet):
|
||||
queryset = PatientJourneyTemplate.objects.all()
|
||||
serializer_class = PatientJourneyTemplateSerializer
|
||||
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']
|
||||
ordering_fields = ['name', 'created_at']
|
||||
ordering = ['hospital', 'journey_type', 'name']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter templates based on user role"""
|
||||
queryset = super().get_queryset().select_related('hospital').prefetch_related('stages')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all templates
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see templates for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Others see templates for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class PatientJourneyStageTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Journey Stage Templates.
|
||||
|
||||
|
||||
Permissions:
|
||||
- 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']
|
||||
ordering_fields = ['order', 'name']
|
||||
ordering = ['journey_template', 'order']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().select_related('journey_template', 'survey_template')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all stage templates
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see stage templates for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(journey_template__hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Journey Instances.
|
||||
|
||||
|
||||
Permissions:
|
||||
- All authenticated users can view journey instances
|
||||
- PX Admins and Hospital Admins can create/manage instances
|
||||
@ -102,60 +102,61 @@ class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = [
|
||||
'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']
|
||||
ordering_fields = ['started_at', 'completed_at']
|
||||
ordering = ['-started_at']
|
||||
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use simplified serializer for list view"""
|
||||
if self.action == 'list':
|
||||
return PatientJourneyInstanceListSerializer
|
||||
return PatientJourneyInstanceSerializer
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter journey instances based on user role"""
|
||||
queryset = super().get_queryset().select_related(
|
||||
'journey_template', 'patient', 'hospital', 'department'
|
||||
).prefetch_related('stage_instances__stage_template')
|
||||
|
||||
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all journey instances
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see instances for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Department Managers see instances for their department
|
||||
if user.is_department_manager() and user.department:
|
||||
return queryset.filter(department=user.department)
|
||||
|
||||
|
||||
# Others see instances for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""
|
||||
Create journey instance and initialize stage instances.
|
||||
|
||||
|
||||
When a journey instance is created, automatically create
|
||||
stage instances for all stages in the template.
|
||||
"""
|
||||
journey_instance = serializer.save()
|
||||
|
||||
|
||||
# Create stage instances for all stages in the template
|
||||
for stage_template in journey_instance.journey_template.stages.filter(is_active=True):
|
||||
PatientJourneyStageInstance.objects.create(
|
||||
journey_instance=journey_instance,
|
||||
stage_template=stage_template
|
||||
)
|
||||
|
||||
|
||||
# Log journey creation
|
||||
AuditService.log_from_request(
|
||||
event_type='journey_started',
|
||||
@ -167,14 +168,14 @@ class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
||||
'patient_mrn': journey_instance.patient.mrn
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def progress(self, request, pk=None):
|
||||
"""Get journey progress summary"""
|
||||
journey = self.get_object()
|
||||
|
||||
|
||||
stages = journey.stage_instances.select_related('stage_template').order_by('stage_template__order')
|
||||
|
||||
|
||||
progress_data = {
|
||||
'journey_id': str(journey.id),
|
||||
'encounter_id': journey.encounter_id,
|
||||
@ -196,14 +197,14 @@ class PatientJourneyInstanceViewSet(viewsets.ModelViewSet):
|
||||
for stage in stages
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
return Response(progress_data)
|
||||
|
||||
|
||||
class PatientJourneyStageInstanceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for Journey Stage Instances (read-only).
|
||||
|
||||
|
||||
Stage instances are created automatically and updated via event processing.
|
||||
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']
|
||||
ordering_fields = ['completed_at', 'created_at']
|
||||
ordering = ['journey_instance', 'stage_template__order']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter stage instances based on user role"""
|
||||
queryset = super().get_queryset().select_related(
|
||||
@ -224,23 +225,23 @@ class PatientJourneyStageInstanceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'department',
|
||||
'survey_instance'
|
||||
)
|
||||
|
||||
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all stage instances
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see instances for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(journey_instance__hospital=user.hospital)
|
||||
|
||||
|
||||
# Department Managers see instances for their department
|
||||
if user.is_department_manager() and user.department:
|
||||
return queryset.filter(journey_instance__department=user.department)
|
||||
|
||||
|
||||
# Others see instances for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(journey_instance__hospital=user.hospital)
|
||||
|
||||
|
||||
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 uuid
|
||||
|
||||
@ -20,23 +20,23 @@ logger = logging.getLogger(__name__)
|
||||
class NotificationService:
|
||||
"""
|
||||
Unified notification service for all channels.
|
||||
|
||||
|
||||
Usage:
|
||||
NotificationService.send_sms('+966501234567', 'Your survey is ready')
|
||||
NotificationService.send_email('user@email.com', 'Survey', 'Please complete...')
|
||||
"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def send_sms(phone, message, related_object=None, metadata=None):
|
||||
"""
|
||||
Send SMS notification.
|
||||
|
||||
|
||||
Args:
|
||||
phone: Recipient phone number
|
||||
message: SMS message text
|
||||
related_object: Related model instance (optional)
|
||||
metadata: Additional metadata dict (optional)
|
||||
|
||||
|
||||
Returns:
|
||||
NotificationLog instance
|
||||
"""
|
||||
@ -49,14 +49,14 @@ class NotificationService:
|
||||
provider='console', # TODO: Replace with actual provider
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
|
||||
# Check if SMS is enabled
|
||||
sms_config = settings.NOTIFICATION_CHANNELS.get('sms', {})
|
||||
if not sms_config.get('enabled', False):
|
||||
logger.info(f"[SMS Console] To: {phone} | Message: {message}")
|
||||
log.mark_sent()
|
||||
return log
|
||||
|
||||
|
||||
# TODO: Integrate with actual SMS provider (Twilio, etc.)
|
||||
# Example:
|
||||
# try:
|
||||
@ -70,24 +70,24 @@ class NotificationService:
|
||||
# log.mark_sent(provider_message_id=message.sid)
|
||||
# except Exception as e:
|
||||
# log.mark_failed(str(e))
|
||||
|
||||
|
||||
# Console backend for now
|
||||
logger.info(f"[SMS Console] To: {phone} | Message: {message}")
|
||||
log.mark_sent()
|
||||
|
||||
|
||||
return log
|
||||
|
||||
|
||||
@staticmethod
|
||||
def send_whatsapp(phone, message, related_object=None, metadata=None):
|
||||
"""
|
||||
Send WhatsApp notification.
|
||||
|
||||
|
||||
Args:
|
||||
phone: Recipient phone number
|
||||
message: WhatsApp message text
|
||||
related_object: Related model instance (optional)
|
||||
metadata: Additional metadata dict (optional)
|
||||
|
||||
|
||||
Returns:
|
||||
NotificationLog instance
|
||||
"""
|
||||
@ -100,14 +100,14 @@ class NotificationService:
|
||||
provider='console', # TODO: Replace with actual provider
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
|
||||
# Check if WhatsApp is enabled
|
||||
whatsapp_config = settings.NOTIFICATION_CHANNELS.get('whatsapp', {})
|
||||
if not whatsapp_config.get('enabled', False):
|
||||
logger.info(f"[WhatsApp Console] To: {phone} | Message: {message}")
|
||||
log.mark_sent()
|
||||
return log
|
||||
|
||||
|
||||
# TODO: Integrate with WhatsApp Business API
|
||||
# Example:
|
||||
# try:
|
||||
@ -123,18 +123,18 @@ class NotificationService:
|
||||
# log.mark_sent(provider_message_id=response.json().get('id'))
|
||||
# except Exception as e:
|
||||
# log.mark_failed(str(e))
|
||||
|
||||
|
||||
# Console backend for now
|
||||
logger.info(f"[WhatsApp Console] To: {phone} | Message: {message}")
|
||||
log.mark_sent()
|
||||
|
||||
|
||||
return log
|
||||
|
||||
|
||||
@staticmethod
|
||||
def send_email(email, subject, message, html_message=None, related_object=None, metadata=None):
|
||||
"""
|
||||
Send Email notification.
|
||||
|
||||
|
||||
Args:
|
||||
email: Recipient email address
|
||||
subject: Email subject
|
||||
@ -142,7 +142,7 @@ class NotificationService:
|
||||
html_message: Email message (HTML) (optional)
|
||||
related_object: Related model instance (optional)
|
||||
metadata: Additional metadata dict (optional)
|
||||
|
||||
|
||||
Returns:
|
||||
NotificationLog instance
|
||||
"""
|
||||
@ -156,14 +156,14 @@ class NotificationService:
|
||||
provider='console', # TODO: Replace with actual provider
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
|
||||
# Check if Email is enabled
|
||||
email_config = settings.NOTIFICATION_CHANNELS.get('email', {})
|
||||
if not email_config.get('enabled', True):
|
||||
logger.info(f"[Email Console] To: {email} | Subject: {subject} | Message: {message}")
|
||||
log.mark_sent()
|
||||
return log
|
||||
|
||||
|
||||
# Send email using Django's email backend
|
||||
try:
|
||||
send_mail(
|
||||
@ -179,24 +179,101 @@ class NotificationService:
|
||||
except Exception as e:
|
||||
log.mark_failed(str(e))
|
||||
logger.error(f"Failed to send email to {email}: {str(e)}")
|
||||
|
||||
|
||||
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
|
||||
def send_survey_invitation(survey_instance, language='en'):
|
||||
"""
|
||||
Send survey invitation to patient.
|
||||
|
||||
|
||||
Args:
|
||||
survey_instance: SurveyInstance object
|
||||
language: Language code ('en' or 'ar')
|
||||
|
||||
|
||||
Returns:
|
||||
NotificationLog instance
|
||||
"""
|
||||
patient = survey_instance.patient
|
||||
survey_url = survey_instance.get_survey_url()
|
||||
|
||||
|
||||
# Determine recipient based on delivery channel
|
||||
if survey_instance.delivery_channel == 'sms':
|
||||
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}"
|
||||
else:
|
||||
message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}"
|
||||
|
||||
|
||||
return NotificationService.send_sms(
|
||||
phone=recipient,
|
||||
message=message,
|
||||
related_object=survey_instance,
|
||||
metadata={'survey_id': str(survey_instance.id), 'language': language}
|
||||
)
|
||||
|
||||
|
||||
elif survey_instance.delivery_channel == 'whatsapp':
|
||||
recipient = survey_instance.recipient_phone or patient.phone
|
||||
if language == 'ar':
|
||||
message = f"عزيزي {patient.get_full_name()},\n\nنرجو منك إكمال استبيان تجربتك:\n{survey_url}"
|
||||
else:
|
||||
message = f"Dear {patient.get_full_name()},\n\nPlease complete your experience survey:\n{survey_url}"
|
||||
|
||||
|
||||
return NotificationService.send_whatsapp(
|
||||
phone=recipient,
|
||||
message=message,
|
||||
related_object=survey_instance,
|
||||
metadata={'survey_id': str(survey_instance.id), 'language': language}
|
||||
)
|
||||
|
||||
|
||||
else: # email
|
||||
recipient = survey_instance.recipient_email or patient.email
|
||||
if language == 'ar':
|
||||
@ -234,7 +311,7 @@ class NotificationService:
|
||||
else:
|
||||
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}"
|
||||
|
||||
|
||||
return NotificationService.send_email(
|
||||
email=recipient,
|
||||
subject=subject,
|
||||
|
||||
@ -3,7 +3,25 @@ Organizations 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)
|
||||
@ -13,14 +31,15 @@ class HospitalAdmin(admin.ModelAdmin):
|
||||
list_filter = ['status', 'city']
|
||||
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'name_ar', 'code')}),
|
||||
(None, {'fields': ('organization', 'name', 'name_ar', 'code')}),
|
||||
('Contact Information', {'fields': ('address', 'city', 'phone', 'email')}),
|
||||
('Details', {'fields': ('license_number', 'capacity', 'status')}),
|
||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||
)
|
||||
|
||||
autocomplete_fields = ['organization']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
@ -32,7 +51,7 @@ class DepartmentAdmin(admin.ModelAdmin):
|
||||
search_fields = ['name', 'name_ar', 'code']
|
||||
ordering = ['hospital', 'name']
|
||||
autocomplete_fields = ['hospital', 'parent', 'manager']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('hospital', 'name', 'name_ar', 'code')}),
|
||||
('Hierarchy', {'fields': ('parent', 'manager')}),
|
||||
@ -40,63 +59,40 @@ class DepartmentAdmin(admin.ModelAdmin):
|
||||
('Status', {'fields': ('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('hospital', 'manager', 'parent')
|
||||
|
||||
|
||||
@admin.register(Physician)
|
||||
class PhysicianAdmin(admin.ModelAdmin):
|
||||
"""Physician admin"""
|
||||
list_display = ['get_full_name', 'license_number', 'specialization', 'hospital', 'department', 'status']
|
||||
list_filter = ['status', 'hospital', 'specialization']
|
||||
search_fields = ['first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'license_number']
|
||||
@admin.register(Staff)
|
||||
class StaffAdmin(admin.ModelAdmin):
|
||||
"""Staff admin"""
|
||||
list_display = ['__str__', 'staff_type', 'job_title', 'employee_id', 'hospital', 'department', 'status']
|
||||
list_filter = ['status', 'hospital', 'staff_type', 'specialization']
|
||||
search_fields = ['first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'employee_id', 'license_number', 'job_title']
|
||||
ordering = ['last_name', 'first_name']
|
||||
autocomplete_fields = ['hospital', 'department', 'user']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(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')}),
|
||||
('Account', {'fields': ('user',)}),
|
||||
('Contact', {'fields': ('phone', 'email')}),
|
||||
('Status', {'fields': ('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('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)
|
||||
class PatientAdmin(admin.ModelAdmin):
|
||||
"""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']
|
||||
ordering = ['last_name', 'first_name']
|
||||
autocomplete_fields = ['primary_hospital']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('mrn', 'national_id')}),
|
||||
('Personal Information', {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
||||
@ -115,9 +111,9 @@ class PatientAdmin(admin.ModelAdmin):
|
||||
('Status', {'fields': ('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('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 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)),
|
||||
('license_number', models.CharField(blank=True, max_length=100)),
|
||||
('capacity', models.IntegerField(blank=True, help_text='Bed capacity', null=True)),
|
||||
('metadata', models.JSONField(blank=True, default=dict, help_text='Hospital configuration settings')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Hospitals',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Organization',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
|
||||
('code', models.CharField(db_index=True, max_length=50, unique=True)),
|
||||
('phone', models.CharField(blank=True, max_length=20)),
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('address', models.TextField(blank=True)),
|
||||
('city', models.CharField(blank=True, max_length=100)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
|
||||
('logo', models.ImageField(blank=True, null=True, upload_to='organizations/logos/')),
|
||||
('website', models.URLField(blank=True)),
|
||||
('license_number', models.CharField(blank=True, max_length=100)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organization',
|
||||
'verbose_name_plural': 'Organizations',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Department',
|
||||
fields=[
|
||||
@ -59,23 +84,10 @@ class Migration(migrations.Migration):
|
||||
'unique_together': {('hospital', 'code')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Employee',
|
||||
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)),
|
||||
('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.AddField(
|
||||
model_name='hospital',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, help_text='Parent organization (null for backward compatibility)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hospitals', to='organizations.organization'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Patient',
|
||||
@ -103,7 +115,7 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Physician',
|
||||
name='Staff',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
@ -112,17 +124,18 @@ class Migration(migrations.Migration):
|
||||
('last_name', models.CharField(max_length=100)),
|
||||
('first_name_ar', models.CharField(blank=True, max_length=100)),
|
||||
('last_name_ar', models.CharField(blank=True, max_length=100)),
|
||||
('license_number', models.CharField(db_index=True, max_length=100, unique=True)),
|
||||
('specialization', models.CharField(max_length=200)),
|
||||
('phone', models.CharField(blank=True, max_length=20)),
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
|
||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='physicians', to='organizations.department')),
|
||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='physicians', to='organizations.hospital')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='physician_profile', to=settings.AUTH_USER_MODEL)),
|
||||
('staff_type', models.CharField(choices=[('physician', 'Physician'), ('nurse', 'Nurse'), ('admin', 'Administrative'), ('other', 'Other')], max_length=20)),
|
||||
('job_title', models.CharField(max_length=200)),
|
||||
('license_number', models.CharField(blank=True, max_length=100, null=True, unique=True)),
|
||||
('specialization', models.CharField(blank=True, max_length=200)),
|
||||
('employee_id', models.CharField(db_index=True, max_length=50, unique=True)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='active', max_length=20)),
|
||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='organizations.department')),
|
||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff', to='organizations.hospital')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['last_name', 'first_name'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -6,8 +6,50 @@ from django.db import models
|
||||
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):
|
||||
"""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_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
||||
code = models.CharField(max_length=50, unique=True, db_index=True)
|
||||
@ -29,6 +71,7 @@ class Hospital(UUIDModel, TimeStampedModel):
|
||||
# Metadata
|
||||
license_number = models.CharField(max_length=100, blank=True)
|
||||
capacity = models.IntegerField(null=True, blank=True, help_text="Bed capacity")
|
||||
metadata = models.JSONField(default=dict, blank=True, help_text="Hospital configuration settings")
|
||||
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
@ -38,8 +81,11 @@ class Hospital(UUIDModel, TimeStampedModel):
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
<<<<<<< HEAD
|
||||
|
||||
# TODO: Add branch
|
||||
=======
|
||||
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||
|
||||
class Department(UUIDModel, TimeStampedModel):
|
||||
"""Department within a hospital"""
|
||||
@ -87,96 +133,137 @@ class Department(UUIDModel, TimeStampedModel):
|
||||
def __str__(self):
|
||||
return f"{self.hospital.name} - {self.name}"
|
||||
|
||||
# TODO Add Section
|
||||
class Physician(UUIDModel, TimeStampedModel):
|
||||
"""Physician/Doctor model"""
|
||||
# Link to user account (optional - some physicians may not have system access)
|
||||
|
||||
class Staff(UUIDModel, TimeStampedModel):
|
||||
class StaffType(models.TextChoices):
|
||||
PHYSICIAN = 'physician', 'Physician'
|
||||
NURSE = 'nurse', 'Nurse'
|
||||
ADMIN = 'admin', 'Administrative'
|
||||
OTHER = 'other', 'Other'
|
||||
|
||||
# Link to User (Keep it optional for external/temp staff)
|
||||
user = models.OneToOneField(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='physician_profile'
|
||||
null=True, blank=True,
|
||||
related_name='staff_profile'
|
||||
)
|
||||
|
||||
# Basic information
|
||||
# Unified Identity (AI will search these 4 fields)
|
||||
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)
|
||||
# Role Logic
|
||||
staff_type = models.CharField(max_length=20, choices=StaffType.choices)
|
||||
job_title = models.CharField(max_length=200) # "Cardiologist", "Senior Nurse", etc.
|
||||
|
||||
# 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
|
||||
# Professional Data (Nullable for non-physicians)
|
||||
license_number = models.CharField(max_length=100, unique=True, null=True, blank=True)
|
||||
specialization = models.CharField(max_length=200, blank=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
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=StatusChoices.choices,
|
||||
default=StatusChoices.ACTIVE,
|
||||
db_index=True
|
||||
)
|
||||
# Organization
|
||||
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='staff')
|
||||
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['user__last_name', 'user__first_name']
|
||||
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE)
|
||||
|
||||
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):
|
||||
@ -229,3 +316,27 @@ class Patient(UUIDModel, TimeStampedModel):
|
||||
|
||||
def get_full_name(self):
|
||||
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 .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):
|
||||
"""Hospital serializer"""
|
||||
|
||||
organization_name = serializers.CharField(source='organization.name', read_only=True)
|
||||
departments_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Hospital
|
||||
fields = [
|
||||
'id', 'name', 'name_ar', 'code', 'address', 'city',
|
||||
'phone', 'email', 'status', 'license_number', 'capacity',
|
||||
'id', 'organization', 'organization_name', 'name', 'name_ar', 'code',
|
||||
'address', 'city', 'phone', 'email', 'status',
|
||||
'license_number', 'capacity', 'departments_count',
|
||||
'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):
|
||||
"""Department serializer"""
|
||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||
parent_name = serializers.CharField(source='parent.name', read_only=True)
|
||||
manager_name = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Department
|
||||
fields = [
|
||||
@ -34,7 +59,7 @@ class DepartmentSerializer(serializers.ModelSerializer):
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_manager_name(self, obj):
|
||||
"""Get manager full name"""
|
||||
if obj.manager:
|
||||
@ -42,52 +67,32 @@ class DepartmentSerializer(serializers.ModelSerializer):
|
||||
return None
|
||||
|
||||
|
||||
class PhysicianSerializer(serializers.ModelSerializer):
|
||||
"""Physician serializer"""
|
||||
class StaffSerializer(serializers.ModelSerializer):
|
||||
"""Staff serializer"""
|
||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||
department_name = serializers.CharField(source='department.name', read_only=True)
|
||||
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:
|
||||
model = Physician
|
||||
model = Staff
|
||||
fields = [
|
||||
'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',
|
||||
'phone', 'email', 'status',
|
||||
'user_email', 'status',
|
||||
'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):
|
||||
"""Patient serializer"""
|
||||
primary_hospital_name = serializers.CharField(source='primary_hospital.name', read_only=True)
|
||||
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||
age = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Patient
|
||||
fields = [
|
||||
@ -99,7 +104,7 @@ class PatientSerializer(serializers.ModelSerializer):
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_age(self, obj):
|
||||
"""Calculate patient age"""
|
||||
if obj.date_of_birth:
|
||||
@ -115,7 +120,7 @@ class PatientListSerializer(serializers.ModelSerializer):
|
||||
"""Simplified patient serializer for list views"""
|
||||
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||
primary_hospital_name = serializers.CharField(source='primary_hospital.name', read_only=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Patient
|
||||
fields = [
|
||||
|
||||
@ -1,29 +1,26 @@
|
||||
"""
|
||||
Organizations Console UI views
|
||||
"""
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import render
|
||||
|
||||
from .models import Department, Hospital, Patient, Physician
|
||||
from .models import Department, Hospital, Organization, Patient, Staff
|
||||
|
||||
|
||||
@login_required
|
||||
def hospital_list(request):
|
||||
"""Hospitals list view"""
|
||||
queryset = Hospital.objects.all()
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
queryset = queryset.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
@ -32,22 +29,22 @@ def hospital_list(request):
|
||||
Q(name_ar__icontains=search_query) |
|
||||
Q(code__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,
|
||||
'hospitals': page_obj.object_list,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'organizations/hospital_list.html', context)
|
||||
|
||||
|
||||
@ -55,21 +52,21 @@ def hospital_list(request):
|
||||
def department_list(request):
|
||||
"""Departments list view"""
|
||||
queryset = Department.objects.select_related('hospital', 'manager')
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Apply filters
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
@ -78,107 +75,229 @@ def department_list(request):
|
||||
Q(name_ar__icontains=search_query) |
|
||||
Q(code__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Ordering
|
||||
queryset = queryset.order_by('hospital', '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)
|
||||
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'departments': page_obj.object_list,
|
||||
'hospitals': hospitals,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'organizations/department_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def physician_list(request):
|
||||
"""Physicians list view"""
|
||||
queryset = Physician.objects.select_related('hospital', 'department', 'user')
|
||||
|
||||
def staff_list(request):
|
||||
"""Staff list view"""
|
||||
queryset = Staff.objects.select_related('hospital', 'department', 'user')
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Apply filters
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
|
||||
department_filter = request.GET.get('department')
|
||||
if department_filter:
|
||||
queryset = queryset.filter(department_id=department_filter)
|
||||
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
if 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_query = request.GET.get('search')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(employee_id__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
|
||||
queryset = queryset.order_by('last_name', 'first_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)
|
||||
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'physicians': page_obj.object_list,
|
||||
'staff': page_obj.object_list,
|
||||
'hospitals': hospitals,
|
||||
'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
|
||||
def patient_list(request):
|
||||
"""Patients list view"""
|
||||
queryset = Patient.objects.select_related('primary_hospital')
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
queryset = queryset.filter(primary_hospital=user.hospital)
|
||||
|
||||
|
||||
# Apply filters
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(primary_hospital_id=hospital_filter)
|
||||
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
@ -189,26 +308,26 @@ def patient_list(request):
|
||||
Q(national_id__icontains=search_query) |
|
||||
Q(phone__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Ordering
|
||||
queryset = queryset.order_by('last_name', 'first_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)
|
||||
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'patients': page_obj.object_list,
|
||||
'hospitals': hospitals,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'organizations/patient_list.html', context)
|
||||
|
||||
@ -1,25 +1,34 @@
|
||||
from django.urls import include, path
|
||||
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
|
||||
|
||||
app_name = 'organizations'
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'api/organizations', OrganizationViewSet, basename='organization-api')
|
||||
router.register(r'api/hospitals', HospitalViewSet, basename='hospital-api')
|
||||
router.register(r'api/departments', DepartmentViewSet, basename='department-api')
|
||||
router.register(r'api/physicians', PhysicianViewSet, basename='physician-api')
|
||||
router.register(r'api/employees', EmployeeViewSet, basename='employee-api')
|
||||
router.register(r'api/staff', StaffViewSet, basename='staff-api')
|
||||
router.register(r'api/patients', PatientViewSet, basename='patient-api')
|
||||
|
||||
urlpatterns = [
|
||||
# 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('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'),
|
||||
|
||||
|
||||
# API Routes
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@ -1,26 +1,64 @@
|
||||
"""
|
||||
Organizations views and viewsets
|
||||
"""
|
||||
from django.db import models
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
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 (
|
||||
DepartmentSerializer,
|
||||
EmployeeSerializer,
|
||||
HospitalSerializer,
|
||||
OrganizationSerializer,
|
||||
PatientListSerializer,
|
||||
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):
|
||||
"""
|
||||
ViewSet for Hospital model.
|
||||
|
||||
|
||||
Permissions:
|
||||
- PX Admins and Hospital Admins can manage hospitals
|
||||
- Others can view hospitals they belong to
|
||||
@ -28,39 +66,39 @@ class HospitalViewSet(viewsets.ModelViewSet):
|
||||
queryset = Hospital.objects.all()
|
||||
serializer_class = HospitalSerializer
|
||||
permission_classes = [IsAuthenticated, CanAccessHospitalData]
|
||||
filterset_fields = ['status', 'city']
|
||||
filterset_fields = ['status', 'city', 'organization']
|
||||
search_fields = ['name', 'name_ar', 'code', 'city']
|
||||
ordering_fields = ['name', 'created_at']
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter hospitals based on user role"""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all hospitals
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Department Managers see their hospital
|
||||
if user.is_department_manager() and user.hospital:
|
||||
return queryset.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Others see hospitals they're associated with
|
||||
if user.hospital:
|
||||
return queryset.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Department model.
|
||||
|
||||
|
||||
Permissions:
|
||||
- PX Admins and Hospital Admins can manage departments
|
||||
- Department Managers can view their department
|
||||
@ -68,24 +106,24 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
queryset = Department.objects.all()
|
||||
serializer_class = DepartmentSerializer
|
||||
permission_classes = [IsAuthenticated, CanAccessDepartmentData]
|
||||
filterset_fields = ['status', 'hospital', 'parent']
|
||||
filterset_fields = ['status', 'hospital', 'parent', 'hospital__organization']
|
||||
search_fields = ['name', 'name_ar', 'code']
|
||||
ordering_fields = ['name', 'created_at']
|
||||
ordering = ['hospital', 'name']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter departments based on user role"""
|
||||
queryset = super().get_queryset().select_related('hospital', 'parent', 'manager')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all departments
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see departments in their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Department Managers see their department and sub-departments
|
||||
if user.is_department_manager() and user.department:
|
||||
return queryset.filter(
|
||||
@ -93,130 +131,90 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
).filter(
|
||||
models.Q(id=user.department.id) | models.Q(parent=user.department)
|
||||
)
|
||||
|
||||
|
||||
# Others see departments in their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class PhysicianViewSet(viewsets.ModelViewSet):
|
||||
class StaffViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Physician model.
|
||||
|
||||
ViewSet for Staff model.
|
||||
|
||||
Permissions:
|
||||
- PX Admins and Hospital Admins can manage physicians
|
||||
- Others can view physicians
|
||||
- PX Admins and Hospital Admins can manage staff
|
||||
- Others can view staff
|
||||
"""
|
||||
queryset = Physician.objects.all()
|
||||
serializer_class = PhysicianSerializer
|
||||
queryset = StaffModel.objects.all()
|
||||
serializer_class = StaffSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['status', 'hospital', 'department', 'specialization']
|
||||
search_fields = ['first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'license_number']
|
||||
filterset_fields = ['status', 'hospital', 'department', 'staff_type', 'specialization', 'job_title', 'hospital__organization']
|
||||
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 = ['last_name', 'first_name']
|
||||
|
||||
|
||||
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')
|
||||
user = self.request.user
|
||||
|
||||
# PX Admins see all physicians
|
||||
|
||||
# PX Admins see all staff
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
# Hospital Admins see physicians in their hospital
|
||||
|
||||
# Hospital Admins see staff in their hospital
|
||||
if user.is_hospital_admin() and 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:
|
||||
return queryset.filter(department=user.department)
|
||||
|
||||
# Others see physicians in their hospital
|
||||
|
||||
# Others see staff in their hospital
|
||||
if 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()
|
||||
|
||||
|
||||
class PatientViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Patient model.
|
||||
|
||||
|
||||
Permissions:
|
||||
- All authenticated users can view patients
|
||||
- PX Admins and Hospital Admins can manage patients
|
||||
"""
|
||||
queryset = Patient.objects.all()
|
||||
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']
|
||||
ordering_fields = ['last_name', 'created_at']
|
||||
ordering = ['last_name', 'first_name']
|
||||
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use simplified serializer for list view"""
|
||||
if self.action == 'list':
|
||||
return PatientListSerializer
|
||||
return PatientSerializer
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter patients based on user role"""
|
||||
queryset = super().get_queryset().select_related('primary_hospital')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all patients
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see patients in their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(primary_hospital=user.hospital)
|
||||
|
||||
|
||||
# Others see patients in their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(primary_hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
@ -10,18 +10,18 @@ from .models import PhysicianMonthlyRating
|
||||
class PhysicianMonthlyRatingAdmin(admin.ModelAdmin):
|
||||
"""Physician monthly rating admin"""
|
||||
list_display = [
|
||||
'physician', 'year', 'month', 'average_rating',
|
||||
'staff', 'year', 'month', 'average_rating',
|
||||
'total_surveys', 'hospital_rank', 'department_rank'
|
||||
]
|
||||
list_filter = ['year', 'month', 'physician__hospital', 'physician__department']
|
||||
list_filter = ['year', 'month', 'staff__hospital', 'staff__department']
|
||||
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']
|
||||
|
||||
|
||||
fieldsets = (
|
||||
('Physician & Period', {
|
||||
'fields': ('physician', 'year', 'month')
|
||||
'fields': ('staff', 'year', 'month')
|
||||
}),
|
||||
('Ratings', {
|
||||
'fields': (
|
||||
@ -40,9 +40,9 @@ class PhysicianMonthlyRatingAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, 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 uuid
|
||||
@ -31,12 +31,12 @@ class Migration(migrations.Migration):
|
||||
('hospital_rank', models.IntegerField(blank=True, help_text='Rank within hospital', null=True)),
|
||||
('department_rank', models.IntegerField(blank=True, help_text='Rank within department', null=True)),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
('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={
|
||||
'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')],
|
||||
'unique_together': {('physician', 'year', 'month')},
|
||||
'indexes': [models.Index(fields=['staff', '-year', '-month'], name='physicians__staff_i_f4cc8b_idx'), models.Index(fields=['year', 'month', '-average_rating'], name='physicians__year_e38883_idx')],
|
||||
'unique_together': {('staff', 'year', 'month')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -14,19 +14,19 @@ from apps.core.models import TimeStampedModel, UUIDModel
|
||||
class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Physician monthly rating - aggregated from survey responses.
|
||||
|
||||
|
||||
Calculated monthly from all surveys mentioning this physician.
|
||||
"""
|
||||
physician = models.ForeignKey(
|
||||
'organizations.Physician',
|
||||
staff = models.ForeignKey(
|
||||
'organizations.Staff',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='monthly_ratings'
|
||||
)
|
||||
|
||||
|
||||
# Time period
|
||||
year = models.IntegerField(db_index=True)
|
||||
month = models.IntegerField(db_index=True, help_text="1-12")
|
||||
|
||||
|
||||
# Ratings
|
||||
average_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
@ -39,7 +39,7 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
||||
positive_count = models.IntegerField(default=0)
|
||||
neutral_count = models.IntegerField(default=0)
|
||||
negative_count = models.IntegerField(default=0)
|
||||
|
||||
|
||||
# Breakdown by journey stage
|
||||
md_consult_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
@ -47,7 +47,7 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
|
||||
# Ranking
|
||||
hospital_rank = models.IntegerField(
|
||||
null=True,
|
||||
@ -59,17 +59,17 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
help_text="Rank within department"
|
||||
)
|
||||
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-year', '-month', '-average_rating']
|
||||
unique_together = [['physician', 'year', 'month']]
|
||||
unique_together = [['staff', 'year', 'month']]
|
||||
indexes = [
|
||||
models.Index(fields=['physician', '-year', '-month']),
|
||||
models.Index(fields=['staff', '-year', '-month']),
|
||||
models.Index(fields=['year', 'month', '-average_rating']),
|
||||
]
|
||||
|
||||
|
||||
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 apps.organizations.models import Physician
|
||||
from apps.organizations.models import Staff
|
||||
|
||||
from .models import PhysicianMonthlyRating
|
||||
|
||||
@ -13,9 +13,9 @@ class PhysicianSerializer(serializers.ModelSerializer):
|
||||
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||
department_name = serializers.CharField(source='department.name', read_only=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Physician
|
||||
model = Staff
|
||||
fields = [
|
||||
'id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
|
||||
'full_name', 'license_number', 'specialization',
|
||||
@ -28,16 +28,16 @@ class PhysicianSerializer(serializers.ModelSerializer):
|
||||
|
||||
class PhysicianMonthlyRatingSerializer(serializers.ModelSerializer):
|
||||
"""Physician monthly rating serializer"""
|
||||
physician_name = serializers.CharField(source='physician.get_full_name', read_only=True)
|
||||
physician_license = serializers.CharField(source='physician.license_number', read_only=True)
|
||||
hospital_name = serializers.CharField(source='physician.hospital.name', read_only=True)
|
||||
department_name = serializers.CharField(source='physician.department.name', read_only=True)
|
||||
staff_name = serializers.CharField(source='staff.get_full_name', read_only=True)
|
||||
staff_license = serializers.CharField(source='staff.license_number', read_only=True)
|
||||
hospital_name = serializers.CharField(source='staff.hospital.name', read_only=True)
|
||||
department_name = serializers.CharField(source='staff.department.name', read_only=True)
|
||||
month_name = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
class Meta:
|
||||
model = PhysicianMonthlyRating
|
||||
fields = [
|
||||
'id', 'physician', 'physician_name', 'physician_license',
|
||||
'id', 'staff', 'staff_name', 'staff_license',
|
||||
'hospital_name', 'department_name',
|
||||
'year', 'month', 'month_name',
|
||||
'average_rating', 'total_surveys',
|
||||
@ -48,7 +48,7 @@ class PhysicianMonthlyRatingSerializer(serializers.ModelSerializer):
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_month_name(self, obj):
|
||||
"""Get month name"""
|
||||
months = [
|
||||
|
||||
@ -21,40 +21,40 @@ logger = logging.getLogger(__name__)
|
||||
def calculate_monthly_physician_ratings(self, year=None, month=None):
|
||||
"""
|
||||
Calculate physician monthly ratings from survey responses.
|
||||
|
||||
|
||||
This task aggregates all survey responses that mention physicians
|
||||
for a given month and creates/updates PhysicianMonthlyRating records.
|
||||
|
||||
|
||||
Args:
|
||||
year: Year to calculate (default: current year)
|
||||
month: Month to calculate (default: current month)
|
||||
|
||||
|
||||
Returns:
|
||||
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.surveys.models import SurveyInstance, SurveyResponse
|
||||
|
||||
|
||||
try:
|
||||
# Default to current month if not specified
|
||||
now = timezone.now()
|
||||
year = year or now.year
|
||||
month = month or now.month
|
||||
|
||||
|
||||
logger.info(f"Calculating physician ratings for {year}-{month:02d}")
|
||||
|
||||
|
||||
# Get all active physicians
|
||||
physicians = Physician.objects.filter(status='active')
|
||||
|
||||
physicians = Staff.objects.filter(status='active')
|
||||
|
||||
ratings_created = 0
|
||||
ratings_updated = 0
|
||||
|
||||
|
||||
for physician in physicians:
|
||||
# Find all completed surveys mentioning this physician
|
||||
# This assumes surveys have a physician field or question
|
||||
# Adjust based on your actual survey structure
|
||||
|
||||
|
||||
# Option 1: If surveys have a direct physician field
|
||||
surveys = SurveyInstance.objects.filter(
|
||||
status='completed',
|
||||
@ -62,7 +62,7 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
||||
completed_at__month=month,
|
||||
metadata__physician_id=str(physician.id)
|
||||
)
|
||||
|
||||
|
||||
# Option 2: If physician is mentioned in survey responses
|
||||
# You may need to adjust this based on your question structure
|
||||
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
|
||||
text_value__icontains=physician.get_full_name()
|
||||
).values_list('survey_instance_id', flat=True).distinct()
|
||||
|
||||
|
||||
# Combine both approaches
|
||||
survey_ids = set(surveys.values_list('id', flat=True)) | set(physician_responses)
|
||||
|
||||
|
||||
if not survey_ids:
|
||||
logger.debug(f"No surveys found for physician {physician.get_full_name()}")
|
||||
continue
|
||||
|
||||
|
||||
# Get all surveys for this physician
|
||||
physician_surveys = SurveyInstance.objects.filter(id__in=survey_ids)
|
||||
|
||||
|
||||
# Calculate statistics
|
||||
total_surveys = physician_surveys.count()
|
||||
|
||||
|
||||
# Calculate average rating
|
||||
avg_score = physician_surveys.aggregate(
|
||||
avg=Avg('total_score')
|
||||
)['avg']
|
||||
|
||||
|
||||
if avg_score is None:
|
||||
logger.debug(f"No scores found for physician {physician.get_full_name()}")
|
||||
continue
|
||||
|
||||
|
||||
# Count sentiment
|
||||
positive_count = physician_surveys.filter(
|
||||
total_score__gte=4.0
|
||||
).count()
|
||||
|
||||
|
||||
neutral_count = physician_surveys.filter(
|
||||
total_score__gte=3.0,
|
||||
total_score__lt=4.0
|
||||
).count()
|
||||
|
||||
|
||||
negative_count = physician_surveys.filter(
|
||||
total_score__lt=3.0
|
||||
).count()
|
||||
|
||||
|
||||
# Get MD consult specific rating if available
|
||||
md_consult_surveys = physician_surveys.filter(
|
||||
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(
|
||||
avg=Avg('total_score')
|
||||
)['avg']
|
||||
|
||||
|
||||
# Create or update rating
|
||||
rating, created = PhysicianMonthlyRating.objects.update_or_create(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=year,
|
||||
month=month,
|
||||
defaults={
|
||||
@ -135,25 +135,25 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if created:
|
||||
ratings_created += 1
|
||||
else:
|
||||
ratings_updated += 1
|
||||
|
||||
|
||||
logger.debug(
|
||||
f"{'Created' if created else 'Updated'} rating for {physician.get_full_name()}: "
|
||||
f"{avg_score:.2f} ({total_surveys} surveys)"
|
||||
)
|
||||
|
||||
|
||||
# Update rankings
|
||||
update_physician_rankings.delay(year, month)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Completed physician ratings calculation for {year}-{month:02d}: "
|
||||
f"{ratings_created} created, {ratings_updated} updated"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'year': year,
|
||||
@ -161,11 +161,11 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
|
||||
'ratings_created': ratings_created,
|
||||
'ratings_updated': ratings_updated
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error calculating physician ratings: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
|
||||
# Retry the task
|
||||
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):
|
||||
"""
|
||||
Update hospital and department rankings for physicians.
|
||||
|
||||
|
||||
This calculates the rank of each physician within their hospital
|
||||
and department for the specified month.
|
||||
|
||||
|
||||
Args:
|
||||
year: Year
|
||||
month: Month
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Result with number of rankings updated
|
||||
"""
|
||||
from apps.organizations.models import Hospital, Department
|
||||
from apps.physicians.models import PhysicianMonthlyRating
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"Updating physician rankings for {year}-{month:02d}")
|
||||
|
||||
|
||||
rankings_updated = 0
|
||||
|
||||
|
||||
# Update hospital rankings
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
|
||||
|
||||
for hospital in hospitals:
|
||||
# Get all ratings for this hospital
|
||||
ratings = PhysicianMonthlyRating.objects.filter(
|
||||
physician__hospital=hospital,
|
||||
staff__hospital=hospital,
|
||||
year=year,
|
||||
month=month
|
||||
).order_by('-average_rating')
|
||||
|
||||
|
||||
# Assign ranks
|
||||
for rank, rating in enumerate(ratings, start=1):
|
||||
rating.hospital_rank = rank
|
||||
rating.save(update_fields=['hospital_rank'])
|
||||
rankings_updated += 1
|
||||
|
||||
|
||||
# Update department rankings
|
||||
departments = Department.objects.filter(status='active')
|
||||
|
||||
|
||||
for department in departments:
|
||||
# Get all ratings for this department
|
||||
ratings = PhysicianMonthlyRating.objects.filter(
|
||||
physician__department=department,
|
||||
staff__department=department,
|
||||
year=year,
|
||||
month=month
|
||||
).order_by('-average_rating')
|
||||
|
||||
|
||||
# Assign ranks
|
||||
for rank, rating in enumerate(ratings, start=1):
|
||||
rating.department_rank = rank
|
||||
rating.save(update_fields=['department_rank'])
|
||||
|
||||
|
||||
logger.info(f"Updated {rankings_updated} physician rankings for {year}-{month:02d}")
|
||||
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'year': year,
|
||||
'month': month,
|
||||
'rankings_updated': rankings_updated
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error updating physician rankings: {str(e)}"
|
||||
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):
|
||||
"""
|
||||
Generate detailed performance report for a physician.
|
||||
|
||||
|
||||
This creates a comprehensive report including:
|
||||
- Monthly rating
|
||||
- Comparison to previous months
|
||||
- Ranking within hospital/department
|
||||
- Trend analysis
|
||||
|
||||
|
||||
Args:
|
||||
physician_id: UUID of Physician
|
||||
year: Year
|
||||
month: Month
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Performance report data
|
||||
"""
|
||||
from apps.organizations.models import Physician
|
||||
from apps.organizations.models import Staff
|
||||
from apps.physicians.models import PhysicianMonthlyRating
|
||||
|
||||
|
||||
try:
|
||||
physician = Physician.objects.get(id=physician_id)
|
||||
|
||||
physician = Staff.objects.get(id=physician_id)
|
||||
|
||||
# Get current month rating
|
||||
current_rating = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=year,
|
||||
month=month
|
||||
).first()
|
||||
|
||||
|
||||
if not current_rating:
|
||||
return {
|
||||
'status': 'no_data',
|
||||
'reason': f'No rating found for {year}-{month:02d}'
|
||||
}
|
||||
|
||||
|
||||
# Get previous month
|
||||
prev_month = month - 1 if month > 1 else 12
|
||||
prev_year = year if month > 1 else year - 1
|
||||
|
||||
|
||||
previous_rating = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=prev_year,
|
||||
month=prev_month
|
||||
).first()
|
||||
|
||||
|
||||
# Get year-to-date stats
|
||||
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=year
|
||||
)
|
||||
|
||||
|
||||
ytd_avg = ytd_ratings.aggregate(avg=Avg('average_rating'))['avg']
|
||||
ytd_surveys = ytd_ratings.aggregate(total=Count('total_surveys'))['total']
|
||||
|
||||
|
||||
# Calculate trend
|
||||
trend = 'stable'
|
||||
if previous_rating:
|
||||
@ -306,7 +306,7 @@ def generate_physician_performance_report(physician_id, year, month):
|
||||
trend = 'improving'
|
||||
elif diff < -0.1:
|
||||
trend = 'declining'
|
||||
|
||||
|
||||
report = {
|
||||
'status': 'success',
|
||||
'physician': {
|
||||
@ -333,16 +333,16 @@ def generate_physician_performance_report(physician_id, year, month):
|
||||
},
|
||||
'trend': trend
|
||||
}
|
||||
|
||||
|
||||
logger.info(f"Generated performance report for {physician.get_full_name()}")
|
||||
|
||||
|
||||
return report
|
||||
|
||||
except Physician.DoesNotExist:
|
||||
|
||||
except Staff.DoesNotExist:
|
||||
error_msg = f"Physician {physician_id} not found"
|
||||
logger.error(error_msg)
|
||||
return {'status': 'error', 'reason': error_msg}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error generating performance report: {str(e)}"
|
||||
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():
|
||||
"""
|
||||
Scheduled task to calculate physician ratings for the previous month.
|
||||
|
||||
|
||||
This should be run on the 1st of each month to calculate ratings
|
||||
for the previous month.
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Result of calculation
|
||||
"""
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
# Calculate for previous month
|
||||
now = timezone.now()
|
||||
prev_month = now - relativedelta(months=1)
|
||||
|
||||
|
||||
year = prev_month.year
|
||||
month = prev_month.month
|
||||
|
||||
|
||||
logger.info(f"Scheduled calculation of physician ratings for {year}-{month:02d}")
|
||||
|
||||
|
||||
# Trigger calculation
|
||||
result = calculate_monthly_physician_ratings.delay(year, month)
|
||||
|
||||
|
||||
return {
|
||||
'status': 'scheduled',
|
||||
'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.utils import timezone
|
||||
|
||||
from apps.organizations.models import Department, Hospital, Physician
|
||||
from apps.organizations.models import Department, Hospital, Staff
|
||||
|
||||
from .models import PhysicianMonthlyRating
|
||||
|
||||
@ -16,7 +16,7 @@ from .models import PhysicianMonthlyRating
|
||||
def physician_list(request):
|
||||
"""
|
||||
Physicians list view with filters.
|
||||
|
||||
|
||||
Features:
|
||||
- Server-side pagination
|
||||
- Filters (hospital, department, specialization, status)
|
||||
@ -24,8 +24,8 @@ def physician_list(request):
|
||||
- Current month rating display
|
||||
"""
|
||||
# Base queryset with optimizations
|
||||
queryset = Physician.objects.select_related('hospital', 'department')
|
||||
|
||||
queryset = Staff.objects.select_related('hospital', 'department')
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if user.is_px_admin():
|
||||
@ -34,24 +34,24 @@ def physician_list(request):
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
|
||||
# Apply filters
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
|
||||
department_filter = request.GET.get('department')
|
||||
if department_filter:
|
||||
queryset = queryset.filter(department_id=department_filter)
|
||||
|
||||
|
||||
specialization_filter = request.GET.get('specialization')
|
||||
if specialization_filter:
|
||||
queryset = queryset.filter(specialization__icontains=specialization_filter)
|
||||
|
||||
|
||||
status_filter = request.GET.get('status', 'active')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
@ -61,51 +61,51 @@ def physician_list(request):
|
||||
Q(license_number__icontains=search_query) |
|
||||
Q(specialization__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Ordering
|
||||
order_by = request.GET.get('order_by', 'last_name')
|
||||
queryset = queryset.order_by(order_by)
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Get current month ratings for displayed physicians
|
||||
now = timezone.now()
|
||||
physician_ids = [p.id for p in page_obj.object_list]
|
||||
current_ratings = PhysicianMonthlyRating.objects.filter(
|
||||
physician_id__in=physician_ids,
|
||||
staff_id__in=physician_ids,
|
||||
year=now.year,
|
||||
month=now.month
|
||||
).select_related('physician')
|
||||
|
||||
).select_related('staff')
|
||||
|
||||
# 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
|
||||
for physician in page_obj.object_list:
|
||||
physician.current_rating = ratings_dict.get(physician.id)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
departments = departments.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# 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
|
||||
stats = {
|
||||
'total': queryset.count(),
|
||||
'active': queryset.filter(status='active').count(),
|
||||
}
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'physicians': page_obj.object_list,
|
||||
@ -117,7 +117,7 @@ def physician_list(request):
|
||||
'current_year': now.year,
|
||||
'current_month': now.month,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'physicians/physician_list.html', context)
|
||||
|
||||
|
||||
@ -125,7 +125,7 @@ def physician_list(request):
|
||||
def physician_detail(request, pk):
|
||||
"""
|
||||
Physician detail view with performance metrics.
|
||||
|
||||
|
||||
Features:
|
||||
- Full physician details
|
||||
- Current month rating
|
||||
@ -134,58 +134,58 @@ def physician_detail(request, pk):
|
||||
- Performance trends
|
||||
"""
|
||||
physician = get_object_or_404(
|
||||
Physician.objects.select_related('hospital', 'department'),
|
||||
Staff.objects.select_related('hospital', 'department'),
|
||||
pk=pk
|
||||
)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
if physician.hospital != user.hospital:
|
||||
from django.http import Http404
|
||||
raise Http404("Physician not found")
|
||||
|
||||
|
||||
now = timezone.now()
|
||||
current_year = now.year
|
||||
current_month = now.month
|
||||
|
||||
|
||||
# Get current month rating
|
||||
current_month_rating = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=current_year,
|
||||
month=current_month
|
||||
).first()
|
||||
|
||||
|
||||
# Get previous month rating
|
||||
prev_month = current_month - 1 if current_month > 1 else 12
|
||||
prev_year = current_year if current_month > 1 else current_year - 1
|
||||
previous_month_rating = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=prev_year,
|
||||
month=prev_month
|
||||
).first()
|
||||
|
||||
|
||||
# Get year-to-date stats
|
||||
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=current_year
|
||||
)
|
||||
|
||||
|
||||
ytd_stats = ytd_ratings.aggregate(
|
||||
avg_rating=Avg('average_rating'),
|
||||
total_surveys=Count('id')
|
||||
)
|
||||
|
||||
|
||||
# Get last 12 months ratings
|
||||
ratings_history = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician
|
||||
staff=physician
|
||||
).order_by('-year', '-month')[:12]
|
||||
|
||||
|
||||
# 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()
|
||||
worst_month = all_ratings.order_by('average_rating').first()
|
||||
|
||||
|
||||
# Determine trend
|
||||
trend = 'stable'
|
||||
trend_percentage = 0
|
||||
@ -193,12 +193,12 @@ def physician_detail(request, pk):
|
||||
diff = float(current_month_rating.average_rating - previous_month_rating.average_rating)
|
||||
if previous_month_rating.average_rating > 0:
|
||||
trend_percentage = (diff / float(previous_month_rating.average_rating)) * 100
|
||||
|
||||
|
||||
if diff > 0.1:
|
||||
trend = 'improving'
|
||||
elif diff < -0.1:
|
||||
trend = 'declining'
|
||||
|
||||
|
||||
context = {
|
||||
'physician': physician,
|
||||
'current_month_rating': current_month_rating,
|
||||
@ -213,7 +213,7 @@ def physician_detail(request, pk):
|
||||
'current_year': current_year,
|
||||
'current_month': current_month,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'physicians/physician_detail.html', context)
|
||||
|
||||
|
||||
@ -221,7 +221,7 @@ def physician_detail(request, pk):
|
||||
def leaderboard(request):
|
||||
"""
|
||||
Physician leaderboard view.
|
||||
|
||||
|
||||
Features:
|
||||
- Top-rated physicians for selected period
|
||||
- Filters (hospital, department, month/year)
|
||||
@ -235,42 +235,42 @@ def leaderboard(request):
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
department_filter = request.GET.get('department')
|
||||
limit = int(request.GET.get('limit', 20))
|
||||
|
||||
|
||||
# Build queryset
|
||||
queryset = PhysicianMonthlyRating.objects.filter(
|
||||
year=year,
|
||||
month=month
|
||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
||||
|
||||
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
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
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(physician__hospital_id=hospital_filter)
|
||||
|
||||
queryset = queryset.filter(staff__hospital_id=hospital_filter)
|
||||
|
||||
if department_filter:
|
||||
queryset = queryset.filter(physician__department_id=department_filter)
|
||||
|
||||
queryset = queryset.filter(staff__department_id=department_filter)
|
||||
|
||||
# Order by rating
|
||||
queryset = queryset.order_by('-average_rating')[:limit]
|
||||
|
||||
|
||||
# Get previous month for trend
|
||||
prev_month = month - 1 if month > 1 else 12
|
||||
prev_year = year if month > 1 else year - 1
|
||||
|
||||
|
||||
# Build leaderboard with trends
|
||||
leaderboard = []
|
||||
for rank, rating in enumerate(queryset, start=1):
|
||||
# Get previous month rating for trend
|
||||
prev_rating = PhysicianMonthlyRating.objects.filter(
|
||||
physician=rating.physician,
|
||||
staff=rating.staff,
|
||||
year=prev_year,
|
||||
month=prev_month
|
||||
).first()
|
||||
|
||||
|
||||
trend = 'stable'
|
||||
trend_value = 0
|
||||
if prev_rating:
|
||||
@ -280,42 +280,42 @@ def leaderboard(request):
|
||||
trend = 'up'
|
||||
elif diff < -0.1:
|
||||
trend = 'down'
|
||||
|
||||
|
||||
leaderboard.append({
|
||||
'rank': rank,
|
||||
'rating': rating,
|
||||
'physician': rating.physician,
|
||||
'physician': rating.staff,
|
||||
'trend': trend,
|
||||
'trend_value': trend_value,
|
||||
'prev_rating': prev_rating
|
||||
})
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
departments = departments.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Calculate statistics
|
||||
all_ratings = PhysicianMonthlyRating.objects.filter(year=year, month=month)
|
||||
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(
|
||||
total_physicians=Count('id'),
|
||||
average_rating=Avg('average_rating'),
|
||||
total_surveys=Count('total_surveys')
|
||||
)
|
||||
|
||||
|
||||
# Distribution
|
||||
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()
|
||||
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()
|
||||
|
||||
|
||||
context = {
|
||||
'leaderboard': leaderboard,
|
||||
'year': year,
|
||||
@ -331,7 +331,7 @@ def leaderboard(request):
|
||||
'poor': poor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'physicians/leaderboard.html', context)
|
||||
|
||||
|
||||
@ -339,7 +339,7 @@ def leaderboard(request):
|
||||
def ratings_list(request):
|
||||
"""
|
||||
Monthly ratings list view with filters.
|
||||
|
||||
|
||||
Features:
|
||||
- All monthly ratings
|
||||
- Filters (physician, hospital, department, year, month)
|
||||
@ -348,66 +348,66 @@ def ratings_list(request):
|
||||
"""
|
||||
# Base queryset
|
||||
queryset = PhysicianMonthlyRating.objects.select_related(
|
||||
'physician', 'physician__hospital', 'physician__department'
|
||||
'staff', 'staff__hospital', 'staff__department'
|
||||
)
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
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
|
||||
physician_filter = request.GET.get('physician')
|
||||
if physician_filter:
|
||||
queryset = queryset.filter(physician_id=physician_filter)
|
||||
|
||||
queryset = queryset.filter(staff_id=physician_filter)
|
||||
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
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')
|
||||
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')
|
||||
if year_filter:
|
||||
queryset = queryset.filter(year=int(year_filter))
|
||||
|
||||
|
||||
month_filter = request.GET.get('month')
|
||||
if month_filter:
|
||||
queryset = queryset.filter(month=int(month_filter))
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(physician__first_name__icontains=search_query) |
|
||||
Q(physician__last_name__icontains=search_query) |
|
||||
Q(physician__license_number__icontains=search_query)
|
||||
Q(staff__first_name__icontains=search_query) |
|
||||
Q(staff__last_name__icontains=search_query) |
|
||||
Q(staff__license_number__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Ordering
|
||||
order_by = request.GET.get('order_by', '-year,-month,-average_rating')
|
||||
queryset = queryset.order_by(*order_by.split(','))
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
departments = departments.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Get available years
|
||||
years = PhysicianMonthlyRating.objects.values_list('year', flat=True).distinct().order_by('-year')
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'ratings': page_obj.object_list,
|
||||
@ -416,7 +416,7 @@ def ratings_list(request):
|
||||
'years': years,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'physicians/ratings_list.html', context)
|
||||
|
||||
|
||||
@ -424,7 +424,7 @@ def ratings_list(request):
|
||||
def specialization_overview(request):
|
||||
"""
|
||||
Specialization overview - aggregated ratings by specialization.
|
||||
|
||||
|
||||
Features:
|
||||
- Average rating per specialization
|
||||
- Total physicians per specialization
|
||||
@ -436,28 +436,28 @@ def specialization_overview(request):
|
||||
year = int(request.GET.get('year', now.year))
|
||||
month = int(request.GET.get('month', now.month))
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
|
||||
|
||||
# Base queryset
|
||||
queryset = PhysicianMonthlyRating.objects.filter(
|
||||
year=year,
|
||||
month=month
|
||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
||||
|
||||
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
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
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(physician__hospital_id=hospital_filter)
|
||||
|
||||
queryset = queryset.filter(staff__hospital_id=hospital_filter)
|
||||
|
||||
# Aggregate by specialization
|
||||
from django.db.models import Avg, Count, Sum
|
||||
|
||||
|
||||
specialization_data = {}
|
||||
for rating in queryset:
|
||||
spec = rating.physician.specialization
|
||||
spec = rating.staff.specialization
|
||||
if spec not in specialization_data:
|
||||
specialization_data[spec] = {
|
||||
'specialization': spec,
|
||||
@ -469,7 +469,7 @@ def specialization_overview(request):
|
||||
'total_negative': 0,
|
||||
'ratings_sum': 0,
|
||||
}
|
||||
|
||||
|
||||
specialization_data[spec]['physicians'].append(rating)
|
||||
specialization_data[spec]['total_physicians'] += 1
|
||||
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_negative'] += rating.negative_count
|
||||
specialization_data[spec]['ratings_sum'] += float(rating.average_rating)
|
||||
|
||||
|
||||
# Calculate averages
|
||||
specializations = []
|
||||
for spec, data in specialization_data.items():
|
||||
@ -492,15 +492,15 @@ def specialization_overview(request):
|
||||
'negative_count': data['total_negative'],
|
||||
'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True)
|
||||
})
|
||||
|
||||
|
||||
# Sort by average rating
|
||||
specializations.sort(key=lambda x: x['average_rating'], reverse=True)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
context = {
|
||||
'specializations': specializations,
|
||||
'year': year,
|
||||
@ -508,7 +508,7 @@ def specialization_overview(request):
|
||||
'hospitals': hospitals,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'physicians/specialization_overview.html', context)
|
||||
|
||||
|
||||
@ -516,7 +516,7 @@ def specialization_overview(request):
|
||||
def department_overview(request):
|
||||
"""
|
||||
Department overview - aggregated ratings by department.
|
||||
|
||||
|
||||
Features:
|
||||
- Average rating per department
|
||||
- Total physicians per department
|
||||
@ -528,31 +528,31 @@ def department_overview(request):
|
||||
year = int(request.GET.get('year', now.year))
|
||||
month = int(request.GET.get('month', now.month))
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
|
||||
|
||||
# Base queryset
|
||||
queryset = PhysicianMonthlyRating.objects.filter(
|
||||
year=year,
|
||||
month=month
|
||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
||||
|
||||
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
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
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(physician__hospital_id=hospital_filter)
|
||||
|
||||
queryset = queryset.filter(staff__hospital_id=hospital_filter)
|
||||
|
||||
# Aggregate by department
|
||||
from django.db.models import Avg, Count, Sum
|
||||
|
||||
|
||||
department_data = {}
|
||||
for rating in queryset:
|
||||
dept = rating.physician.department
|
||||
dept = rating.staff.department
|
||||
if not dept:
|
||||
continue
|
||||
|
||||
|
||||
dept_key = str(dept.id)
|
||||
if dept_key not in department_data:
|
||||
department_data[dept_key] = {
|
||||
@ -565,7 +565,7 @@ def department_overview(request):
|
||||
'total_negative': 0,
|
||||
'ratings_sum': 0,
|
||||
}
|
||||
|
||||
|
||||
department_data[dept_key]['physicians'].append(rating)
|
||||
department_data[dept_key]['total_physicians'] += 1
|
||||
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_negative'] += rating.negative_count
|
||||
department_data[dept_key]['ratings_sum'] += float(rating.average_rating)
|
||||
|
||||
|
||||
# Calculate averages
|
||||
departments = []
|
||||
for dept_key, data in department_data.items():
|
||||
@ -588,15 +588,15 @@ def department_overview(request):
|
||||
'negative_count': data['total_negative'],
|
||||
'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True)
|
||||
})
|
||||
|
||||
|
||||
# Sort by average rating
|
||||
departments.sort(key=lambda x: x['average_rating'], reverse=True)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
context = {
|
||||
'departments': departments,
|
||||
'year': year,
|
||||
@ -604,5 +604,5 @@ def department_overview(request):
|
||||
'hospitals': hospitals,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
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 apps.accounts.permissions import IsPXAdminOrHospitalAdmin
|
||||
from apps.organizations.models import Physician
|
||||
from apps.organizations.models import Staff
|
||||
|
||||
from .models import PhysicianMonthlyRating
|
||||
from .serializers import (
|
||||
@ -22,41 +22,41 @@ from .serializers import (
|
||||
class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for Physicians.
|
||||
|
||||
|
||||
Permissions:
|
||||
- All authenticated users can view physicians
|
||||
- Filtered by hospital based on user role
|
||||
"""
|
||||
queryset = Physician.objects.all()
|
||||
queryset = Staff.objects.all()
|
||||
serializer_class = PhysicianSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['hospital', 'department', 'specialization', 'status']
|
||||
search_fields = ['first_name', 'last_name', 'license_number', 'specialization']
|
||||
ordering_fields = ['last_name', 'first_name', 'specialization', 'created_at']
|
||||
ordering = ['last_name', 'first_name']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter physicians based on user role"""
|
||||
queryset = super().get_queryset().select_related('hospital', 'department')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all physicians
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins and staff see physicians for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def performance(self, request, pk=None):
|
||||
"""
|
||||
Get physician performance summary.
|
||||
|
||||
|
||||
GET /api/physicians/{id}/performance/
|
||||
|
||||
|
||||
Returns:
|
||||
- Current month rating
|
||||
- Previous month rating
|
||||
@ -65,47 +65,47 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
- Trend analysis
|
||||
"""
|
||||
physician = self.get_object()
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
now = timezone.now()
|
||||
current_year = now.year
|
||||
current_month = now.month
|
||||
|
||||
|
||||
# Get current month rating
|
||||
current_month_rating = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=current_year,
|
||||
month=current_month
|
||||
).first()
|
||||
|
||||
|
||||
# Get previous month rating
|
||||
prev_month = current_month - 1 if current_month > 1 else 12
|
||||
prev_year = current_year if current_month > 1 else current_year - 1
|
||||
previous_month_rating = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=prev_year,
|
||||
month=prev_month
|
||||
).first()
|
||||
|
||||
|
||||
# Get year-to-date stats
|
||||
ytd_ratings = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician,
|
||||
staff=physician,
|
||||
year=current_year
|
||||
)
|
||||
|
||||
|
||||
ytd_stats = ytd_ratings.aggregate(
|
||||
avg_rating=Avg('average_rating'),
|
||||
total_surveys=Count('id')
|
||||
)
|
||||
|
||||
|
||||
# Get best and worst months (last 12 months)
|
||||
last_12_months = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician
|
||||
staff=physician
|
||||
).order_by('-year', '-month')[:12]
|
||||
|
||||
|
||||
best_month = last_12_months.order_by('-average_rating').first()
|
||||
worst_month = last_12_months.order_by('average_rating').first()
|
||||
|
||||
|
||||
# Determine trend
|
||||
trend = 'stable'
|
||||
if current_month_rating and previous_month_rating:
|
||||
@ -113,7 +113,7 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
trend = 'improving'
|
||||
elif current_month_rating.average_rating < previous_month_rating.average_rating:
|
||||
trend = 'declining'
|
||||
|
||||
|
||||
# Build response
|
||||
data = {
|
||||
'physician': PhysicianSerializer(physician).data,
|
||||
@ -125,26 +125,26 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'worst_month': PhysicianMonthlyRatingSerializer(worst_month).data if worst_month else None,
|
||||
'trend': trend
|
||||
}
|
||||
|
||||
|
||||
serializer = PhysicianPerformanceSerializer(data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def ratings_history(self, request, pk=None):
|
||||
"""
|
||||
Get physician ratings history.
|
||||
|
||||
|
||||
GET /api/physicians/{id}/ratings_history/?months=12
|
||||
|
||||
|
||||
Returns monthly ratings for the specified number of months.
|
||||
"""
|
||||
physician = self.get_object()
|
||||
months = int(request.query_params.get('months', 12))
|
||||
|
||||
|
||||
ratings = PhysicianMonthlyRating.objects.filter(
|
||||
physician=physician
|
||||
staff=physician
|
||||
).order_by('-year', '-month')[:months]
|
||||
|
||||
|
||||
serializer = PhysicianMonthlyRatingSerializer(ratings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -152,7 +152,7 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for Physician Monthly Ratings.
|
||||
|
||||
|
||||
Permissions:
|
||||
- All authenticated users can view ratings
|
||||
- Filtered by hospital based on user role
|
||||
@ -160,136 +160,136 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = PhysicianMonthlyRating.objects.all()
|
||||
serializer_class = PhysicianMonthlyRatingSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['physician', 'year', 'month', 'physician__hospital', 'physician__department']
|
||||
search_fields = ['physician__first_name', 'physician__last_name', 'physician__license_number']
|
||||
filterset_fields = ['staff', 'year', 'month', 'staff__hospital', 'staff__department']
|
||||
search_fields = ['staff__first_name', 'staff__last_name', 'staff__license_number']
|
||||
ordering_fields = ['year', 'month', 'average_rating', 'total_surveys', 'hospital_rank', 'department_rank']
|
||||
ordering = ['-year', '-month', '-average_rating']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter ratings based on user role"""
|
||||
queryset = super().get_queryset().select_related(
|
||||
'physician',
|
||||
'physician__hospital',
|
||||
'physician__department'
|
||||
'staff',
|
||||
'staff__hospital',
|
||||
'staff__department'
|
||||
)
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all ratings
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins and staff see ratings for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(physician__hospital=user.hospital)
|
||||
|
||||
return queryset.filter(staff__hospital=user.hospital)
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def leaderboard(self, request):
|
||||
"""
|
||||
Get physician leaderboard.
|
||||
|
||||
|
||||
GET /api/physicians/ratings/leaderboard/?year=2024&month=12&hospital={id}&department={id}&limit=10
|
||||
|
||||
|
||||
Returns top-rated physicians for the specified period.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
# Get parameters
|
||||
year = int(request.query_params.get('year', timezone.now().year))
|
||||
month = int(request.query_params.get('month', timezone.now().month))
|
||||
hospital_id = request.query_params.get('hospital')
|
||||
department_id = request.query_params.get('department')
|
||||
limit = int(request.query_params.get('limit', 10))
|
||||
|
||||
|
||||
# Build queryset
|
||||
queryset = PhysicianMonthlyRating.objects.filter(
|
||||
year=year,
|
||||
month=month
|
||||
).select_related('physician', 'physician__hospital', 'physician__department')
|
||||
|
||||
).select_related('staff', 'staff__hospital', 'staff__department')
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
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
|
||||
if hospital_id:
|
||||
queryset = queryset.filter(physician__hospital_id=hospital_id)
|
||||
|
||||
queryset = queryset.filter(staff__hospital_id=hospital_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
|
||||
queryset = queryset.order_by('-average_rating')[:limit]
|
||||
|
||||
|
||||
# Get previous month for trend
|
||||
prev_month = month - 1 if month > 1 else 12
|
||||
prev_year = year if month > 1 else year - 1
|
||||
|
||||
|
||||
# Build leaderboard data
|
||||
leaderboard = []
|
||||
for rank, rating in enumerate(queryset, start=1):
|
||||
# Get previous month rating for trend
|
||||
prev_rating = PhysicianMonthlyRating.objects.filter(
|
||||
physician=rating.physician,
|
||||
staff=rating.staff,
|
||||
year=prev_year,
|
||||
month=prev_month
|
||||
).first()
|
||||
|
||||
|
||||
trend = 'stable'
|
||||
if prev_rating:
|
||||
if rating.average_rating > prev_rating.average_rating:
|
||||
trend = 'up'
|
||||
elif rating.average_rating < prev_rating.average_rating:
|
||||
trend = 'down'
|
||||
|
||||
|
||||
leaderboard.append({
|
||||
'physician_id': rating.physician.id,
|
||||
'physician_name': rating.physician.get_full_name(),
|
||||
'physician_license': rating.physician.license_number,
|
||||
'specialization': rating.physician.specialization,
|
||||
'department_name': rating.physician.department.name if rating.physician.department else '',
|
||||
'physician_id': rating.staff.id,
|
||||
'physician_name': rating.staff.get_full_name(),
|
||||
'physician_license': rating.staff.license_number,
|
||||
'specialization': rating.staff.specialization,
|
||||
'department_name': rating.staff.department.name if rating.staff.department else '',
|
||||
'average_rating': rating.average_rating,
|
||||
'total_surveys': rating.total_surveys,
|
||||
'rank': rank,
|
||||
'trend': trend
|
||||
})
|
||||
|
||||
|
||||
serializer = PhysicianLeaderboardSerializer(leaderboard, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def statistics(self, request):
|
||||
"""
|
||||
Get physician rating statistics.
|
||||
|
||||
|
||||
GET /api/physicians/ratings/statistics/?year=2024&month=12&hospital={id}
|
||||
|
||||
|
||||
Returns aggregate statistics for the specified period.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
# Get parameters
|
||||
year = int(request.query_params.get('year', timezone.now().year))
|
||||
month = int(request.query_params.get('month', timezone.now().month))
|
||||
hospital_id = request.query_params.get('hospital')
|
||||
|
||||
|
||||
# Build queryset
|
||||
queryset = PhysicianMonthlyRating.objects.filter(
|
||||
year=year,
|
||||
month=month
|
||||
)
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
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
|
||||
if hospital_id:
|
||||
queryset = queryset.filter(physician__hospital_id=hospital_id)
|
||||
|
||||
queryset = queryset.filter(staff__hospital_id=hospital_id)
|
||||
|
||||
# Calculate statistics
|
||||
stats = queryset.aggregate(
|
||||
total_physicians=Count('id'),
|
||||
@ -299,13 +299,13 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
total_neutral=Count('neutral_count'),
|
||||
total_negative=Count('negative_count')
|
||||
)
|
||||
|
||||
|
||||
# Get distribution
|
||||
excellent = queryset.filter(average_rating__gte=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()
|
||||
poor = queryset.filter(average_rating__lt=2.5).count()
|
||||
|
||||
|
||||
return Response({
|
||||
'year': year,
|
||||
'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 uuid
|
||||
@ -12,11 +12,27 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organizations', '0001_initial'),
|
||||
('px_action_center', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='QIProjectTask',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=500)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
||||
('due_date', models.DateField(blank=True, null=True)),
|
||||
('completed_date', models.DateField(blank=True, null=True)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['project', 'order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QIProject',
|
||||
fields=[
|
||||
@ -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')),
|
||||
('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)),
|
||||
('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={
|
||||
'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 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 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 uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -10,7 +11,9 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('journeys', '0001_initial'),
|
||||
('organizations', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -21,13 +24,40 @@ class Migration(migrations.Migration):
|
||||
('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)),
|
||||
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
|
||||
('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')),
|
||||
],
|
||||
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(
|
||||
@ -37,14 +67,71 @@ class Migration(migrations.Migration):
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('encounter_id', models.CharField(blank=True, db_index=True, max_length=100)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('delivery_channel', models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email')], default='sms', max_length=20)),
|
||||
('recipient_phone', models.CharField(blank=True, max_length=20)),
|
||||
('recipient_email', models.EmailField(blank=True, max_length=254)),
|
||||
('access_token', models.CharField(blank=True, db_index=True, help_text='Secure token for survey access', max_length=100, unique=True)),
|
||||
('token_expires_at', models.DateTimeField(blank=True, help_text='Token expiration date', null=True)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20)),
|
||||
('sent_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('opened_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('total_score', models.DecimalField(blank=True, decimal_places=2, help_text='Calculated total score', max_digits=5, null=True)),
|
||||
('is_negative', models.BooleanField(db_index=True, default=False, help_text='True if score below threshold')),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
('patient_contacted', models.BooleanField(default=False, help_text='Whether patient was contacted about negative survey')),
|
||||
('patient_contacted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('contact_notes', models.TextField(blank=True, help_text='Notes from patient contact')),
|
||||
('issue_resolved', models.BooleanField(default=False, help_text='Whether the issue was resolved/explained')),
|
||||
('satisfaction_feedback_sent', models.BooleanField(default=False, help_text='Whether satisfaction feedback form was sent')),
|
||||
('satisfaction_feedback_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')),
|
||||
('journey_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneyinstance')),
|
||||
('journey_stage_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneystageinstance')),
|
||||
('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='organizations.patient')),
|
||||
('patient_contacted_by', models.ForeignKey(blank=True, help_text='User who contacted the patient', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacted_surveys', to=settings.AUTH_USER_MODEL)),
|
||||
('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='surveys.surveytemplate')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SurveyResponse',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('numeric_value', models.DecimalField(blank=True, decimal_places=2, help_text='For rating, NPS, Likert questions', max_digits=10, null=True)),
|
||||
('text_value', models.TextField(blank=True, help_text='For text, textarea questions')),
|
||||
('choice_value', models.CharField(blank=True, help_text='For multiple choice questions', max_length=200)),
|
||||
('response_time_seconds', models.IntegerField(blank=True, help_text='Time taken to answer this question', null=True)),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='responses', to='surveys.surveyquestion')),
|
||||
('survey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='surveys.surveyinstance')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['survey_instance', 'question__order'],
|
||||
'unique_together': {('survey_instance', 'question')},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='surveytemplate',
|
||||
index=models.Index(fields=['hospital', 'survey_type', 'is_active'], name='surveys_sur_hospita_0c8e30_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='surveyquestion',
|
||||
index=models.Index(fields=['survey_template', 'order'], name='surveys_sur_survey__d8acd5_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='surveyinstance',
|
||||
index=models.Index(fields=['patient', '-created_at'], name='surveys_sur_patient_7e68b1_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='surveyinstance',
|
||||
index=models.Index(fields=['status', '-sent_at'], name='surveys_sur_status_ce377b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='surveyinstance',
|
||||
index=models.Index(fields=['is_negative', '-completed_at'], name='surveys_sur_is_nega_46c933_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@ -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.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):
|
||||
@ -31,7 +31,7 @@ class QuestionType(BaseChoices):
|
||||
class SurveyTemplate(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Survey template defines questions for a survey.
|
||||
|
||||
|
||||
Supports:
|
||||
- Bilingual questions (AR/EN)
|
||||
- Multiple question types
|
||||
@ -42,14 +42,14 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
|
||||
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
||||
description = models.TextField(blank=True)
|
||||
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
|
||||
|
||||
|
||||
# Configuration
|
||||
hospital = models.ForeignKey(
|
||||
'organizations.Hospital',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='survey_templates'
|
||||
)
|
||||
|
||||
|
||||
# Survey type
|
||||
survey_type = models.CharField(
|
||||
max_length=50,
|
||||
@ -62,7 +62,7 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
|
||||
default='stage',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
# Scoring configuration
|
||||
scoring_method = models.CharField(
|
||||
max_length=20,
|
||||
@ -79,22 +79,22 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
|
||||
default=3.0,
|
||||
help_text="Scores below this trigger PX actions (out of 5)"
|
||||
)
|
||||
|
||||
|
||||
# Configuration
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
|
||||
|
||||
# Metadata
|
||||
version = models.IntegerField(default=1)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['hospital', 'name']
|
||||
indexes = [
|
||||
models.Index(fields=['hospital', 'survey_type', 'is_active']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
def get_question_count(self):
|
||||
"""Get number of questions"""
|
||||
return self.questions.count()
|
||||
@ -103,7 +103,7 @@ class SurveyTemplate(UUIDModel, TimeStampedModel):
|
||||
class SurveyQuestion(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Survey question within a template.
|
||||
|
||||
|
||||
Supports:
|
||||
- Bilingual text (AR/EN)
|
||||
- Multiple question types
|
||||
@ -115,11 +115,11 @@ class SurveyQuestion(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='questions'
|
||||
)
|
||||
|
||||
|
||||
# Question text
|
||||
text = models.TextField(verbose_name="Question Text (English)")
|
||||
text_ar = models.TextField(blank=True, verbose_name="Question Text (Arabic)")
|
||||
|
||||
|
||||
# Question configuration
|
||||
question_type = models.CharField(
|
||||
max_length=20,
|
||||
@ -128,14 +128,14 @@ class SurveyQuestion(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
order = models.IntegerField(default=0, help_text="Display order")
|
||||
is_required = models.BooleanField(default=True)
|
||||
|
||||
|
||||
# For multiple choice questions
|
||||
choices_json = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]"
|
||||
)
|
||||
|
||||
|
||||
# Scoring
|
||||
weight = models.DecimalField(
|
||||
max_digits=3,
|
||||
@ -143,51 +143,53 @@ class SurveyQuestion(UUIDModel, TimeStampedModel):
|
||||
default=1.0,
|
||||
help_text="Weight for weighted average scoring"
|
||||
)
|
||||
|
||||
|
||||
# Branch logic
|
||||
branch_logic = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Conditional display logic: {'show_if': {'question_id': 'value'}}"
|
||||
)
|
||||
|
||||
|
||||
# Help text
|
||||
help_text = models.TextField(blank=True)
|
||||
help_text_ar = models.TextField(blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['survey_template', 'order']
|
||||
indexes = [
|
||||
models.Index(fields=['survey_template', 'order']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
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.
|
||||
|
||||
|
||||
Linked to:
|
||||
- Survey template (defines questions)
|
||||
- Patient (recipient)
|
||||
- Journey stage (optional - if stage survey)
|
||||
- Encounter (optional)
|
||||
|
||||
Tenant-aware: All surveys are scoped to a hospital.
|
||||
"""
|
||||
survey_template = models.ForeignKey(
|
||||
SurveyTemplate,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='instances'
|
||||
)
|
||||
|
||||
|
||||
# Patient information
|
||||
patient = models.ForeignKey(
|
||||
'organizations.Patient',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='surveys'
|
||||
)
|
||||
|
||||
|
||||
# Journey linkage (for stage surveys)
|
||||
journey_instance = models.ForeignKey(
|
||||
'journeys.PatientJourneyInstance',
|
||||
@ -204,7 +206,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
related_name='surveys'
|
||||
)
|
||||
encounter_id = models.CharField(max_length=100, blank=True, db_index=True)
|
||||
|
||||
|
||||
# Delivery
|
||||
delivery_channel = models.CharField(
|
||||
max_length=20,
|
||||
@ -217,7 +219,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
recipient_phone = models.CharField(max_length=20, blank=True)
|
||||
recipient_email = models.EmailField(blank=True)
|
||||
|
||||
|
||||
# Access token for secure link
|
||||
access_token = models.CharField(
|
||||
max_length=100,
|
||||
@ -231,7 +233,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
help_text="Token expiration date"
|
||||
)
|
||||
|
||||
|
||||
# Status
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
@ -239,12 +241,12 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
default=StatusChoices.PENDING,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
# Timestamps
|
||||
sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
opened_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
# Scoring
|
||||
total_score = models.DecimalField(
|
||||
max_digits=5,
|
||||
@ -258,10 +260,10 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
db_index=True,
|
||||
help_text="True if score below threshold"
|
||||
)
|
||||
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
# Patient contact tracking (for negative surveys)
|
||||
patient_contacted = models.BooleanField(
|
||||
default=False,
|
||||
@ -284,14 +286,14 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
default=False,
|
||||
help_text="Whether the issue was resolved/explained"
|
||||
)
|
||||
|
||||
|
||||
# Satisfaction feedback tracking
|
||||
satisfaction_feedback_sent = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether satisfaction feedback form was sent"
|
||||
)
|
||||
satisfaction_feedback_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
@ -299,15 +301,15 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
models.Index(fields=['status', '-sent_at']),
|
||||
models.Index(fields=['is_negative', '-completed_at']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.survey_template.name} - {self.patient.get_full_name()}"
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate access token on creation"""
|
||||
if not self.access_token:
|
||||
self.access_token = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# Set token expiration
|
||||
if not self.token_expires_at:
|
||||
from datetime import timedelta
|
||||
@ -315,24 +317,24 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
from django.utils import timezone
|
||||
days = getattr(settings, 'SURVEY_TOKEN_EXPIRY_DAYS', 30)
|
||||
self.token_expires_at = timezone.now() + timedelta(days=days)
|
||||
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def get_survey_url(self):
|
||||
"""Generate secure survey URL"""
|
||||
# TODO: Implement in Phase 4 UI
|
||||
return f"/surveys/{self.access_token}/"
|
||||
|
||||
|
||||
def calculate_score(self):
|
||||
"""
|
||||
Calculate total score from responses.
|
||||
|
||||
|
||||
Returns the calculated score and updates the instance.
|
||||
"""
|
||||
responses = self.responses.all()
|
||||
if not responses.exists():
|
||||
return None
|
||||
|
||||
|
||||
if self.survey_template.scoring_method == 'average':
|
||||
# Simple average of all rating responses
|
||||
rating_responses = responses.filter(
|
||||
@ -344,7 +346,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
score = total / count if count > 0 else 0
|
||||
else:
|
||||
score = 0
|
||||
|
||||
|
||||
elif self.survey_template.scoring_method == 'weighted':
|
||||
# Weighted average based on question weights
|
||||
total_weighted = 0
|
||||
@ -354,7 +356,7 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
|
||||
total_weighted += float(response.numeric_value) * float(response.question.weight)
|
||||
total_weight += float(response.question.weight)
|
||||
score = total_weighted / total_weight if total_weight > 0 else 0
|
||||
|
||||
|
||||
else: # NPS
|
||||
# NPS calculation: % promoters - % detractors
|
||||
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
|
||||
else:
|
||||
score = 0
|
||||
|
||||
|
||||
# Update instance
|
||||
self.total_score = score
|
||||
self.is_negative = score < float(self.survey_template.negative_threshold)
|
||||
self.save(update_fields=['total_score', 'is_negative'])
|
||||
|
||||
|
||||
return score
|
||||
|
||||
|
||||
@ -388,7 +390,7 @@ class SurveyResponse(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.PROTECT,
|
||||
related_name='responses'
|
||||
)
|
||||
|
||||
|
||||
# Response value (type depends on question type)
|
||||
numeric_value = models.DecimalField(
|
||||
max_digits=10,
|
||||
@ -406,17 +408,17 @@ class SurveyResponse(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
help_text="For multiple choice questions"
|
||||
)
|
||||
|
||||
|
||||
# Metadata
|
||||
response_time_seconds = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Time taken to answer this question"
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['survey_instance', 'question__order']
|
||||
unique_together = [['survey_instance', 'question']]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.survey_instance} - {self.question.text[:30]}"
|
||||
|
||||
@ -25,7 +25,7 @@ from .serializers import (
|
||||
class SurveyTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Survey Templates.
|
||||
|
||||
|
||||
Permissions:
|
||||
- PX Admins and Hospital Admins can manage templates
|
||||
- Others can view templates
|
||||
@ -33,35 +33,35 @@ class SurveyTemplateViewSet(viewsets.ModelViewSet):
|
||||
queryset = SurveyTemplate.objects.all()
|
||||
serializer_class = SurveyTemplateSerializer
|
||||
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']
|
||||
ordering_fields = ['name', 'created_at']
|
||||
ordering = ['hospital', 'name']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter templates based on user role"""
|
||||
queryset = super().get_queryset().select_related('hospital').prefetch_related('questions')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all templates
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see templates for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Others see templates for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class SurveyQuestionViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Survey Questions.
|
||||
|
||||
|
||||
Permissions:
|
||||
- PX Admins and Hospital Admins can manage questions
|
||||
"""
|
||||
@ -72,26 +72,26 @@ class SurveyQuestionViewSet(viewsets.ModelViewSet):
|
||||
search_fields = ['text', 'text_ar']
|
||||
ordering_fields = ['order', 'created_at']
|
||||
ordering = ['survey_template', 'order']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().select_related('survey_template')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all questions
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see questions for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(survey_template__hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class SurveyInstanceViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Survey Instances.
|
||||
|
||||
|
||||
Permissions:
|
||||
- All authenticated users can view survey instances
|
||||
- PX Admins and Hospital Admins can create/manage instances
|
||||
@ -101,56 +101,57 @@ class SurveyInstanceViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = [
|
||||
'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']
|
||||
ordering_fields = ['sent_at', 'completed_at', 'created_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter survey instances based on user role"""
|
||||
queryset = super().get_queryset().select_related(
|
||||
'survey_template', 'patient', 'journey_instance', 'journey_stage_instance'
|
||||
).prefetch_related('responses')
|
||||
|
||||
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all survey instances
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see instances for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(survey_template__hospital=user.hospital)
|
||||
|
||||
|
||||
# Others see instances for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(survey_template__hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def resend(self, request, pk=None):
|
||||
"""Resend survey invitation"""
|
||||
survey_instance = self.get_object()
|
||||
|
||||
|
||||
if survey_instance.status == 'completed':
|
||||
return Response(
|
||||
{'error': 'Cannot resend completed survey'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
# Queue survey send task
|
||||
from apps.surveys.tasks import send_survey_reminder
|
||||
send_survey_reminder.delay(str(survey_instance.id))
|
||||
|
||||
|
||||
return Response({'message': 'Survey invitation queued for resend'})
|
||||
|
||||
|
||||
class SurveyResponseViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for Survey Responses (read-only).
|
||||
|
||||
|
||||
Responses are created via the public survey submission endpoint.
|
||||
"""
|
||||
queryset = SurveyResponse.objects.all()
|
||||
@ -158,38 +159,38 @@ class SurveyResponseViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['survey_instance', 'question']
|
||||
ordering = ['survey_instance', 'question__order']
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().select_related('survey_instance', 'question')
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all responses
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
|
||||
# Hospital Admins see responses for their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(survey_instance__survey_template__hospital=user.hospital)
|
||||
|
||||
|
||||
# Others see responses for their hospital
|
||||
if user.hospital:
|
||||
return queryset.filter(survey_instance__survey_template__hospital=user.hospital)
|
||||
|
||||
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class PublicSurveyViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
Public survey viewset for patient-facing survey access.
|
||||
|
||||
|
||||
No authentication required - uses secure token.
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
def retrieve(self, request, token=None):
|
||||
"""
|
||||
Get survey by access token.
|
||||
|
||||
|
||||
GET /api/surveys/public/{token}/
|
||||
"""
|
||||
survey_instance = get_object_or_404(
|
||||
@ -198,36 +199,36 @@ class PublicSurveyViewSet(viewsets.GenericViewSet):
|
||||
),
|
||||
access_token=token
|
||||
)
|
||||
|
||||
|
||||
# Check if token expired
|
||||
if survey_instance.token_expires_at and survey_instance.token_expires_at < timezone.now():
|
||||
return Response(
|
||||
{'error': 'Survey link has expired'},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
|
||||
# Check if already completed
|
||||
if survey_instance.status == 'completed':
|
||||
return Response(
|
||||
{'error': 'Survey already completed', 'completed_at': survey_instance.completed_at},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
|
||||
# Mark as opened if first time
|
||||
if not survey_instance.opened_at:
|
||||
survey_instance.opened_at = timezone.now()
|
||||
survey_instance.save(update_fields=['opened_at'])
|
||||
|
||||
|
||||
serializer = PublicSurveySerializer(survey_instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='(?P<token>[^/.]+)/submit')
|
||||
def submit(self, request, token=None):
|
||||
"""
|
||||
Submit survey responses.
|
||||
|
||||
|
||||
POST /api/surveys/public/{token}/submit/
|
||||
|
||||
|
||||
Body:
|
||||
{
|
||||
"responses": [
|
||||
@ -240,21 +241,21 @@ class PublicSurveyViewSet(viewsets.GenericViewSet):
|
||||
SurveyInstance,
|
||||
access_token=token
|
||||
)
|
||||
|
||||
|
||||
# Check if token expired
|
||||
if survey_instance.token_expires_at and survey_instance.token_expires_at < timezone.now():
|
||||
return Response(
|
||||
{'error': 'Survey link has expired'},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
|
||||
# Check if already completed
|
||||
if survey_instance.status == 'completed':
|
||||
return Response(
|
||||
{'error': 'Survey already completed'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
# Validate and create responses
|
||||
serializer = SurveySubmissionSerializer(
|
||||
data=request.data,
|
||||
@ -262,7 +263,7 @@ class PublicSurveyViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
|
||||
return Response({
|
||||
'message': 'Survey submitted successfully',
|
||||
'score': float(survey_instance.total_score) if survey_instance.total_score else None
|
||||
|
||||
@ -77,6 +77,7 @@ MIDDLEWARE = [
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'apps.core.middleware.TenantMiddleware', # Multi-tenancy support
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
@ -96,6 +97,7 @@ TEMPLATES = [
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.i18n',
|
||||
'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_CHANNELS = {
|
||||
'sms': {
|
||||
@ -346,3 +354,13 @@ DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@px360.sa')
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
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",
|
||||
"djangorestframework-stubs>=3.16.6",
|
||||
"rich>=14.2.0",
|
||||
<<<<<<< HEAD
|
||||
"reportlab>=4.4.7",
|
||||
"openpyxl>=3.1.5",
|
||||
=======
|
||||
"litellm>=1.0.0",
|
||||
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load math %}
|
||||
|
||||
{% block title %}Complaint #{{ complaint.id|slice:":8" }} - PX360{% endblock %}
|
||||
|
||||
@ -33,7 +34,7 @@
|
||||
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
||||
.status-closed { background: #f5f5f5; color: #616161; }
|
||||
.status-cancelled { background: #ffebee; color: #d32f2f; }
|
||||
|
||||
|
||||
.severity-badge {
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
@ -44,7 +45,7 @@
|
||||
.severity-medium { background: #fff3e0; color: #f57c00; }
|
||||
.severity-high { background: #ffebee; color: #d32f2f; }
|
||||
.severity-critical { background: #880e4f; color: #fff; }
|
||||
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
@ -86,7 +87,7 @@
|
||||
.timeline-item.note::before {
|
||||
border-color: #388e3c;
|
||||
}
|
||||
|
||||
|
||||
.action-card {
|
||||
border-left: 4px solid #667eea;
|
||||
transition: transform 0.2s;
|
||||
@ -94,7 +95,7 @@
|
||||
.action-card:hover {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
@ -102,7 +103,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
|
||||
.info-value {
|
||||
font-size: 1rem;
|
||||
color: #212529;
|
||||
@ -176,25 +177,25 @@
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs mb-3" id="complaintTabs" role="tablist">
|
||||
<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">
|
||||
<i class="bi bi-info-circle me-1"></i> {{ _("Details") }}
|
||||
</button>
|
||||
</li>
|
||||
<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">
|
||||
<i class="bi bi-clock-history me-1"></i> {{ _("Timeline") }} ({{ timeline.count }})
|
||||
</button>
|
||||
</li>
|
||||
<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">
|
||||
<i class="bi bi-paperclip me-1"></i> {{ _("Attachments") }} ({{ attachments.count }})
|
||||
</button>
|
||||
</li>
|
||||
<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">
|
||||
<i class="bi bi-lightning-fill me-1"></i> {{ _("PX Actions")}} ({{ px_actions.count }})
|
||||
</button>
|
||||
@ -208,7 +209,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-4">{% trans "Complaint Details" %}</h5>
|
||||
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="info-label">{{ _("Category") }}</div>
|
||||
@ -224,7 +225,7 @@
|
||||
<div class="info-value">{{ complaint.get_source_display }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="info-label">{{ _("Priority") }}</div>
|
||||
@ -243,7 +244,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if complaint.physician %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
@ -255,16 +256,89 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="info-label">{{ _("Description") }}</div>
|
||||
<div class="info-value mt-2">
|
||||
<p class="mb-0">{{ complaint.description|linebreaks }}</p>
|
||||
</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 %}
|
||||
<hr>
|
||||
<div class="mb-3">
|
||||
@ -273,16 +347,21 @@
|
||||
<div class="alert alert-success">
|
||||
<p class="mb-2">{{ complaint.resolution|linebreaks }}</p>
|
||||
<small class="text-muted">
|
||||
<<<<<<< HEAD
|
||||
{{ _("Resolved by")}} {{ complaint.resolved_by.get_full_name }}
|
||||
{{ _("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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="info-label">{{ _("Created") }}</div>
|
||||
@ -302,7 +381,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-4">{% trans "Activity Timeline" %}</h5>
|
||||
|
||||
|
||||
{% if timeline %}
|
||||
<div class="timeline">
|
||||
{% for update in timeline %}
|
||||
@ -354,7 +433,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-4">{% trans "Attachments" %}</h5>
|
||||
|
||||
|
||||
{% if attachments %}
|
||||
<div class="list-group">
|
||||
{% for attachment in attachments %}
|
||||
@ -365,7 +444,7 @@
|
||||
<strong>{{ attachment.filename }}</strong>
|
||||
<br>
|
||||
<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" }}
|
||||
({{ attachment.file_size|filesizeformat }})
|
||||
</small>
|
||||
@ -374,7 +453,7 @@
|
||||
<small>{{ attachment.description }}</small>
|
||||
{% endif %}
|
||||
</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>
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
@ -397,7 +476,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-4">{% trans "Related PX Actions" %}</h5>
|
||||
|
||||
|
||||
{% if px_actions %}
|
||||
{% for action in px_actions %}
|
||||
<div class="card action-card mb-3">
|
||||
@ -411,7 +490,7 @@
|
||||
<span class="badge bg-secondary ms-1">{{ action.get_priority_display }}</span>
|
||||
</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">
|
||||
{{ _("View") }} <i class="bi bi-arrow-right ms-1"></i>
|
||||
</a>
|
||||
@ -448,7 +527,7 @@
|
||||
<select name="user_id" class="form-select" required>
|
||||
<option value="">{{ _("Select user...")}}</option>
|
||||
{% 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 %}>
|
||||
{{ user_obj.get_full_name }}
|
||||
</option>
|
||||
@ -459,7 +538,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<!-- Change Status -->
|
||||
<form method="post" action="{% url 'complaints:complaint_change_status' complaint.id %}" class="mb-3">
|
||||
{% csrf_token %}
|
||||
@ -471,22 +550,22 @@
|
||||
</option>
|
||||
{% endfor %}
|
||||
</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>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-arrow-repeat me-1"></i> {{ _("Update Status")}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
<!-- 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">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i> {{ _("Escalate") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Add Note -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header bg-success text-white">
|
||||
@ -495,7 +574,7 @@
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'complaints:complaint_add_note' complaint.id %}">
|
||||
{% 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>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-plus-circle me-1"></i> {{ _("Add Note")}}
|
||||
@ -503,7 +582,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Assignment Info -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
@ -524,7 +603,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if complaint.resolved_by %}
|
||||
<div class="mb-3">
|
||||
<div class="info-label">{{ _("Resolved By")}}</div>
|
||||
@ -537,7 +616,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if complaint.closed_by %}
|
||||
<div class="mb-0">
|
||||
<div class="info-label">{{ _("Closed By")}}</div>
|
||||
@ -552,7 +631,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Resolution Survey -->
|
||||
{% if complaint.resolution_survey %}
|
||||
<div class="card">
|
||||
@ -561,7 +640,11 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<<<<<<< HEAD
|
||||
<strong>{{ _("Status") }}:</strong>
|
||||
=======
|
||||
<strong>Status:</strong>
|
||||
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||
<span class="badge bg-{{ complaint.resolution_survey.status }}">
|
||||
{{ complaint.resolution_survey.get_status_display }}
|
||||
</span>
|
||||
@ -571,7 +654,7 @@
|
||||
<strong>{{ _("Score") }}:</strong> {{ complaint.resolution_survey.score }}/100
|
||||
</p>
|
||||
{% 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">
|
||||
{{ _("View Survey")}} <i class="bi bi-arrow-right ms-1"></i>
|
||||
</a>
|
||||
@ -599,7 +682,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -44,9 +44,10 @@
|
||||
|
||||
<form method="post" action="{% url 'complaints:complaint_create' %}" id="complaintForm">
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<<<<<<< HEAD
|
||||
<!-- Patient Information -->
|
||||
<div class="form-section">
|
||||
<h5 class="form-section-title">
|
||||
@ -70,12 +71,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
=======
|
||||
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||
<!-- Organization Information -->
|
||||
<div class="form-section">
|
||||
<h5 class="form-section-title">
|
||||
<i class="bi bi-hospital me-2"></i>{{ _("Organization") }}
|
||||
</h5>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label required-field">{% trans "Hospital" %}</label>
|
||||
@ -86,41 +89,93 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{% trans "Department" %}</label>
|
||||
<select name="department_id" class="form-select" id="departmentSelect">
|
||||
<<<<<<< HEAD
|
||||
<option value="">{{ _("Select department")}}</option>
|
||||
=======
|
||||
<option value="">Select hospital first...</option>
|
||||
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<<<<<<< HEAD
|
||||
<label class="form-label">{% trans "Physician" %}</label>
|
||||
<select name="physician_id" class="form-select" id="physicianSelect">
|
||||
<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>
|
||||
</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 -->
|
||||
<div class="form-section">
|
||||
<h5 class="form-section-title">
|
||||
<i class="bi bi-file-text me-2"></i>{{ _("Complaint Details")}}
|
||||
</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">
|
||||
<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>
|
||||
<<<<<<< HEAD
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@ -180,6 +235,13 @@
|
||||
</select>
|
||||
</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">
|
||||
<label class="form-label required-field">{% trans "Source" %}</label>
|
||||
<select name="source" class="form-select" required>
|
||||
@ -196,14 +258,41 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLA Information -->
|
||||
<!-- Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<!-- AI Information -->
|
||||
<div class="alert alert-info">
|
||||
<h6 class="alert-heading">
|
||||
<<<<<<< HEAD
|
||||
<i class="bi bi-info-circle me-2"></i>{{ _("SLA Information")}}
|
||||
</h6>
|
||||
<p class="mb-0 small">
|
||||
{{ _("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>
|
||||
<ul class="mb-0 mt-2 small">
|
||||
<li><strong>{{ _("Critical") }}:</strong> {{ _("4 hours")}}</li>
|
||||
@ -230,66 +319,149 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize Select2 for patient search (if Select2 is available)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Hospital change handler - load departments
|
||||
const hospitalSelect = document.getElementById('hospitalSelect');
|
||||
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() {
|
||||
const hospitalId = this.value;
|
||||
|
||||
// Clear department and physician
|
||||
departmentSelect.innerHTML = '<option value="">Select department...</option>';
|
||||
physicianSelect.innerHTML = '<option value="">Select physician...</option>';
|
||||
|
||||
|
||||
// Clear dependent dropdowns
|
||||
departmentSelect.innerHTML = '<option value="">Select hospital first...</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) {
|
||||
// Load departments for selected hospital
|
||||
// Load departments
|
||||
fetch(`/api/organizations/departments/?hospital=${hospitalId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
departmentSelect.innerHTML = '<option value="">Select department...</option>';
|
||||
data.results.forEach(dept => {
|
||||
const option = document.createElement('option');
|
||||
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);
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Error loading departments:', error));
|
||||
|
||||
// Load physicians for selected hospital
|
||||
fetch(`/api/organizations/physicians/?hospital=${hospitalId}`)
|
||||
.catch(error => {
|
||||
console.error('Error loading departments:', error);
|
||||
departmentSelect.innerHTML = '<option value="">Error loading departments</option>';
|
||||
});
|
||||
|
||||
// Load categories (using public API endpoint)
|
||||
fetch(`/complaints/public/api/load-categories/?hospital_id=${hospitalId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
data.results.forEach(physician => {
|
||||
const option = document.createElement('option');
|
||||
option.value = physician.id;
|
||||
option.textContent = `Dr. ${physician.first_name} ${physician.last_name} (${physician.specialty})`;
|
||||
physicianSelect.appendChild(option);
|
||||
categorySelect.innerHTML = '<option value="">Select category...</option>';
|
||||
data.categories.forEach(cat => {
|
||||
// Only show parent categories (no parent_id)
|
||||
if (!cat.parent_id) {
|
||||
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
|
||||
const patientSelect = document.getElementById('patientSelect');
|
||||
let patientSearchTimeout;
|
||||
|
||||
// Simple patient search (can be enhanced with Select2)
|
||||
|
||||
// Department change handler - load staff
|
||||
departmentSelect.addEventListener('change', function() {
|
||||
const departmentId = this.value;
|
||||
|
||||
// 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() {
|
||||
if (this.options.length === 1) {
|
||||
// Load initial patients
|
||||
loadPatients('');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function loadPatients(searchTerm) {
|
||||
const url = searchTerm
|
||||
const url = searchTerm
|
||||
? `/api/organizations/patients/?search=${encodeURIComponent(searchTerm)}`
|
||||
: '/api/organizations/patients/?page_size=50';
|
||||
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@ -303,7 +475,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
})
|
||||
.catch(error => console.error('Error loading patients:', error));
|
||||
}
|
||||
|
||||
|
||||
// Form validation
|
||||
const form = document.getElementById('complaintForm');
|
||||
form.addEventListener('submit', function(e) {
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
||||
.status-closed { background: #f5f5f5; color: #616161; }
|
||||
.status-cancelled { background: #ffebee; color: #d32f2f; }
|
||||
|
||||
|
||||
.severity-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
@ -51,7 +51,7 @@
|
||||
.severity-medium { background: #fff3e0; color: #f57c00; }
|
||||
.severity-high { background: #ffebee; color: #d32f2f; }
|
||||
.severity-critical { background: #880e4f; color: #fff; }
|
||||
|
||||
|
||||
.overdue-badge {
|
||||
background: #d32f2f;
|
||||
color: white;
|
||||
@ -61,12 +61,12 @@
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
|
||||
.complaint-row:hover {
|
||||
background: #f8f9fa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.stat-card {
|
||||
border-left: 4px solid;
|
||||
transition: transform 0.2s;
|
||||
@ -171,18 +171,18 @@
|
||||
<i class="bi bi-chevron-up" id="filterToggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="filter-body">
|
||||
<form method="get" action="{% url 'complaints:complaint_list' %}" id="filterForm">
|
||||
<div class="row g-3">
|
||||
<!-- Search -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% trans "Search" %}</label>
|
||||
<input type="text" class="form-control" name="search"
|
||||
placeholder="{% trans 'Title, MRN, Patient name...' %}"
|
||||
<input type="text" class="form-control" name="search"
|
||||
placeholder="{% trans 'Title, MRN, Patient name...' %}"
|
||||
value="{{ filters.search }}">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% trans "Status" %}</label>
|
||||
@ -195,7 +195,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Severity -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% trans "Severity" %}</label>
|
||||
@ -207,7 +207,7 @@
|
||||
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{{ _("Critical") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Priority -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% trans "Priority" %}</label>
|
||||
@ -219,7 +219,7 @@
|
||||
<option value="urgent" {% if filters.priority == 'urgent' %}selected{% endif %}>{{ _("Urgent") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Category -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% trans "Category" %}</label>
|
||||
@ -234,7 +234,7 @@
|
||||
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{{ _("Other") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Hospital -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% trans "Hospital" %}</label>
|
||||
@ -247,7 +247,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Department -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% trans "Department" %}</label>
|
||||
@ -260,7 +260,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Assigned To -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% trans "Assigned To" %}</label>
|
||||
@ -273,7 +273,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Overdue -->
|
||||
<div class="col-md-4">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Date Range -->
|
||||
<div class="col-md-3">
|
||||
<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 }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-search me-1"></i> {{ _("Apply Filters")}}
|
||||
@ -322,7 +322,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complaints Table -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
@ -350,7 +349,7 @@
|
||||
{% for complaint in complaints %}
|
||||
<tr class="complaint-row" onclick="window.location='{% url 'complaints:complaint_detail' complaint.id %}'">
|
||||
<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 }}">
|
||||
</td>
|
||||
<td>
|
||||
@ -401,7 +400,7 @@
|
||||
</td>
|
||||
<td onclick="event.stopPropagation();">
|
||||
<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' %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
@ -438,7 +437,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||
@ -450,7 +449,7 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<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">
|
||||
<!-- Brand -->
|
||||
<div class="sidebar-brand">
|
||||
<i class="bi bi-heart-pulse-fill"></i> PX360
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<ul class="nav flex-column">
|
||||
<!-- Command Center -->
|
||||
<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' %}">
|
||||
<i class="bi bi-speedometer2"></i>
|
||||
{% trans "Command Center" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||
|
||||
|
||||
<!-- Complaints -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'complaints' in request.path %}active{% endif %}"
|
||||
data-bs-toggle="collapse"
|
||||
href="#complaintsMenu"
|
||||
role="button"
|
||||
aria-expanded="{% if 'complaints' in request.path %}true{% else %}false{% endif %}"
|
||||
<a class="nav-link {% if 'complaints' in request.path %}active{% endif %}"
|
||||
data-bs-toggle="collapse"
|
||||
href="#complaintsMenu"
|
||||
role="button"
|
||||
aria-expanded="{% if 'complaints' in request.path %}true{% else %}false{% endif %}"
|
||||
aria-controls="complaintsMenu">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
{% trans "Complaints" %}
|
||||
@ -35,21 +37,21 @@
|
||||
<div class="collapse {% if 'complaints' in request.path %}show{% endif %}" id="complaintsMenu">
|
||||
<ul class="nav flex-column ms-3">
|
||||
<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' %}">
|
||||
<i class="bi bi-list-ul"></i>
|
||||
{% trans "All Complaints" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
{% trans "Inquiries" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
{% trans "Analytics" %}
|
||||
@ -58,24 +60,24 @@
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Feedback -->
|
||||
<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' %}">
|
||||
<i class="bi bi-chat-heart"></i>
|
||||
{% trans "Feedback" %}
|
||||
<span class="badge bg-success">{{ feedback_count|default:0 }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Appreciation -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'appreciation' in request.path %}active{% endif %}"
|
||||
data-bs-toggle="collapse"
|
||||
href="#appreciationMenu"
|
||||
role="button"
|
||||
aria-expanded="{% if 'appreciation' in request.path %}true{% else %}false{% endif %}"
|
||||
<a class="nav-link {% if 'appreciation' in request.path %}active{% endif %}"
|
||||
data-bs-toggle="collapse"
|
||||
href="#appreciationMenu"
|
||||
role="button"
|
||||
aria-expanded="{% if 'appreciation' in request.path %}true{% else %}false{% endif %}"
|
||||
aria-controls="appreciationMenu">
|
||||
<i class="bi bi-heart-fill"></i>
|
||||
{% trans "Appreciation" %}
|
||||
@ -84,28 +86,28 @@
|
||||
<div class="collapse {% if 'appreciation' in request.path %}show{% endif %}" id="appreciationMenu">
|
||||
<ul class="nav flex-column ms-3">
|
||||
<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' %}">
|
||||
<i class="bi bi-list-ul"></i>
|
||||
{% trans "All Appreciations" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-send"></i>
|
||||
{% trans "Send Appreciation" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-trophy"></i>
|
||||
{% trans "Leaderboard" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-award"></i>
|
||||
{% trans "My Badges" %}
|
||||
@ -114,71 +116,72 @@
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Observations -->
|
||||
<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' %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
{% trans "Observations" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
<!-- PX Actions -->
|
||||
<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' %}">
|
||||
<i class="bi bi-clipboard-check"></i>
|
||||
{% trans "PX Actions" %}
|
||||
<span class="badge bg-warning">{{ action_count|default:0 }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Journeys -->
|
||||
<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' %}">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
{% trans "Patient Journeys" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Surveys -->
|
||||
<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' %}">
|
||||
<i class="bi bi-clipboard-data"></i>
|
||||
{% trans "Surveys" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Physicians -->
|
||||
<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' %}">
|
||||
<i class="bi bi-person-badge"></i>
|
||||
{% trans "Physicians" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||
|
||||
|
||||
<!-- Organizations -->
|
||||
<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' %}">
|
||||
<i class="bi bi-building"></i>
|
||||
{% trans "Organizations" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Call Center -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'callcenter' in request.path %}active{% endif %}"
|
||||
data-bs-toggle="collapse"
|
||||
href="#callcenterMenu"
|
||||
role="button"
|
||||
aria-expanded="{% if 'callcenter' in request.path %}true{% else %}false{% endif %}"
|
||||
<a class="nav-link {% if 'callcenter' in request.path %}active{% endif %}"
|
||||
data-bs-toggle="collapse"
|
||||
href="#callcenterMenu"
|
||||
role="button"
|
||||
aria-expanded="{% if 'callcenter' in request.path %}true{% else %}false{% endif %}"
|
||||
aria-controls="callcenterMenu">
|
||||
<i class="bi bi-telephone"></i>
|
||||
{% trans "Call Center" %}
|
||||
@ -187,35 +190,35 @@
|
||||
<div class="collapse {% if 'callcenter' in request.path %}show{% endif %}" id="callcenterMenu">
|
||||
<ul class="nav flex-column ms-3">
|
||||
<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' %}">
|
||||
<i class="bi bi-list-ul"></i>
|
||||
{% trans "Interactions" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
{% trans "Create Complaint" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
{% trans "Create Inquiry" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
{% trans "Complaints" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
{% trans "Inquiries" %}
|
||||
@ -224,47 +227,43 @@
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Social Media -->
|
||||
<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' %}">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
{% trans "Social Media" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||
|
||||
|
||||
<!-- Analytics -->
|
||||
<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' %}">
|
||||
<i class="bi bi-graph-up"></i>
|
||||
{% trans "Analytics" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- QI Projects -->
|
||||
<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' %}">
|
||||
<i class="bi bi-kanban"></i>
|
||||
{% trans "QI Projects" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||
|
||||
|
||||
<!-- Settings (PX Admin only) -->
|
||||
{% if user.is_px_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'config' in request.path %}active{% endif %}"
|
||||
data-bs-toggle="collapse"
|
||||
href="#settingsMenu"
|
||||
role="button"
|
||||
aria-expanded="{% if 'config' in request.path %}true{% else %}false{% endif %}"
|
||||
aria-controls="settingsMenu">
|
||||
<a class="nav-link {% if 'config' in request.path %}active{% endif %}"
|
||||
href="{% url 'config:dashboard' %}">
|
||||
<i class="bi bi-gear"></i>
|
||||
{% trans "Settings" %}
|
||||
<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">
|
||||
<ul class="nav flex-column ms-3">
|
||||
<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' %}">
|
||||
<i class="bi bi-sliders"></i>
|
||||
{% trans "Configuration" %}
|
||||
</a>
|
||||
</li>
|
||||
<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' %}">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
{% trans "Onboarding" %}
|
||||
@ -292,4 +291,71 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
@ -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')">
|
||||
<i class="bi bi-list fs-4"></i>
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Page Title -->
|
||||
<div class="flex-grow-1">
|
||||
<img src="{% static 'img/logo.png' %}" height="50">
|
||||
{# <h5 class="mb-0 text-teal-dark">{% block page_title %}{% trans "Dashboard" %}{% endblock %}</h5>#}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Search -->
|
||||
<div class="me-3 d-none d-md-block">
|
||||
<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);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Notifications -->
|
||||
<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;">
|
||||
@ -52,7 +52,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<div class="dropdown me-3">
|
||||
<button class="btn btn-link text-teal" type="button" data-bs-toggle="dropdown">
|
||||
@ -83,7 +83,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="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>
|
||||
<small style="color: var(--hh-text-muted);">{{ user.get_role_names.0|default:"User" }}</small>
|
||||
</div>
|
||||
{# <div class="avatar avatar-teal">#}
|
||||
{# {{ user.first_name.0|default:user.username.0|upper }}#}
|
||||
{# </div>#}
|
||||
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center"
|
||||
style="width: 40px; height: 40px;">
|
||||
{{ user.first_name.0|default:user.username.0|upper }}
|
||||
</div>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<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