update complain and add ai and sentiment analysis

This commit is contained in:
ismail 2026-01-05 23:24:27 +03:00
parent 7d56370811
commit eb578d9f9b
96 changed files with 9569 additions and 2713 deletions

View File

@ -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
View 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
View 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

View File

@ -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"

View File

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

View File

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

View File

@ -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')

View File

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

View File

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

View File

@ -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})

View File

@ -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'),
]

View File

@ -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
View 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')
}
)
)

View 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.'
)
)

View File

@ -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'),
),
]

View File

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

View 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'),
),
]

View File

@ -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'),
),
]

View File

@ -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})"

View File

@ -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}"

View File

@ -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)

View File

@ -0,0 +1,3 @@
"""
Complaints template tags
"""

View 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

View File

@ -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)),
]

View File

@ -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
View 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()

View File

@ -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
View 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
View 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

View File

@ -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
View 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

View File

@ -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']),
]

View File

@ -0,0 +1 @@
# Template tags for the core app

View 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')

View File

@ -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)

View File

@ -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)

View File

@ -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(

View File

@ -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={

View File

@ -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'),
),
]

View File

@ -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),
),
]

View 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'),
),
]

View File

@ -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')}"

View File

@ -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)

View File

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

View File

@ -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}

View File

@ -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'

View File

@ -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'],

View File

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

View File

@ -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:

View File

@ -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()

View File

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

View File

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

View File

@ -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')

View File

@ -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"))

View File

@ -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,
},
),
]

View File

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

View File

@ -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 = [

View File

@ -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)

View File

@ -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)),
]

View File

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

View File

@ -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')

View File

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

View File

@ -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}"

View File

@ -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 = [

View File

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

View File

@ -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)

View File

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

View File

@ -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'),
),
]

View 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'),
),
]

View File

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

View File

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

View File

@ -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'),
),
]

View File

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

View File

@ -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),
),
]

View File

@ -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]}"

View File

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

View File

@ -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'

View 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
View 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

View 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
View 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`

View 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

View File

@ -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]

View File

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

View File

@ -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) {

View File

@ -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 %}">

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

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

View File

@ -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">

View 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">
&copy; {% 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>

1243
uv.lock generated

File diff suppressed because it is too large Load Diff