diff --git a/.env.example b/.env.example index 00cb165..a1e3509 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/COMPLAINT_CATEGORIES_FIX.md b/COMPLAINT_CATEGORIES_FIX.md new file mode 100644 index 0000000..fb54451 --- /dev/null +++ b/COMPLAINT_CATEGORIES_FIX.md @@ -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 diff --git a/PUBLIC_COMPLAINT_FORM.md b/PUBLIC_COMPLAINT_FORM.md new file mode 100644 index 0000000..33aa428 --- /dev/null +++ b/PUBLIC_COMPLAINT_FORM.md @@ -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//` - 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 diff --git a/PX360/settings.py b/PX360/settings.py index 3af1a1a..f3b4643 100644 --- a/PX360/settings.py +++ b/PX360/settings.py @@ -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" \ No newline at end of file diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py index 67033f4..429fd71 100644 --- a/apps/accounts/migrations/0001_initial.py +++ b/apps/accounts/migrations/0001_initial.py @@ -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 diff --git a/apps/accounts/migrations/0002_initial.py b/apps/accounts/migrations/0002_initial.py index 1b97eea..79d1639 100644 --- a/apps/accounts/migrations/0002_initial.py +++ b/apps/accounts/migrations/0002_initial.py @@ -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 diff --git a/apps/accounts/views.py b/apps/accounts/views.py index bf4f8eb..0e5b831 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -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') diff --git a/apps/analytics/migrations/0001_initial.py b/apps/analytics/migrations/0001_initial.py index 6091199..48cf263 100644 --- a/apps/analytics/migrations/0001_initial.py +++ b/apps/analytics/migrations/0001_initial.py @@ -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 diff --git a/apps/callcenter/migrations/0001_initial.py b/apps/callcenter/migrations/0001_initial.py index 22685ed..b93353c 100644 --- a/apps/callcenter/migrations/0001_initial.py +++ b/apps/callcenter/migrations/0001_initial.py @@ -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 diff --git a/apps/callcenter/ui_views.py b/apps/callcenter/ui_views.py index 55b3aa9..4dd4da0 100644 --- a/apps/callcenter/ui_views.py +++ b/apps/callcenter/ui_views.py @@ -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}) diff --git a/apps/callcenter/urls.py b/apps/callcenter/urls.py index 047a884..68f55cb 100644 --- a/apps/callcenter/urls.py +++ b/apps/callcenter/urls.py @@ -7,19 +7,19 @@ urlpatterns = [ # Interactions path('interactions/', ui_views.interaction_list, name='interaction_list'), path('interactions//', 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//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//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'), ] diff --git a/apps/complaints/admin.py b/apps/complaints/admin.py index f4224ad..d68a0b5 100644 --- a/apps/complaints/admin.py +++ b/apps/complaints/admin.py @@ -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('OVERDUE') - + 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('DUE SOON') 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()}" diff --git a/apps/complaints/forms.py b/apps/complaints/forms.py new file mode 100644 index 0000000..7cb2858 --- /dev/null +++ b/apps/complaints/forms.py @@ -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') + } + ) + ) diff --git a/apps/complaints/management/commands/load_complaint_categories.py b/apps/complaints/management/commands/load_complaint_categories.py new file mode 100644 index 0000000..0c2fac2 --- /dev/null +++ b/apps/complaints/management/commands/load_complaint_categories.py @@ -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.' + ) + ) diff --git a/apps/complaints/migrations/0001_initial.py b/apps/complaints/migrations/0001_initial.py index 1dd1718..6e3b2f1 100644 --- a/apps/complaints/migrations/0001_initial.py +++ b/apps/complaints/migrations/0001_initial.py @@ -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'), - ), ] diff --git a/apps/complaints/migrations/0002_complaintcategory_complaintslaconfig_and_more.py b/apps/complaints/migrations/0002_complaintcategory_complaintslaconfig_and_more.py deleted file mode 100644 index 0539ea5..0000000 --- a/apps/complaints/migrations/0002_complaintcategory_complaintslaconfig_and_more.py +++ /dev/null @@ -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')], - }, - ), - ] diff --git a/apps/complaints/migrations/0002_initial.py b/apps/complaints/migrations/0002_initial.py new file mode 100644 index 0000000..b84e90e --- /dev/null +++ b/apps/complaints/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/apps/complaints/migrations/0003_alter_complaintcategory_options_and_more.py b/apps/complaints/migrations/0003_alter_complaintcategory_options_and_more.py new file mode 100644 index 0000000..86810bb --- /dev/null +++ b/apps/complaints/migrations/0003_alter_complaintcategory_options_and_more.py @@ -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'), + ), + ] diff --git a/apps/complaints/models.py b/apps/complaints/models.py index cd37a2c..a11e7e7 100644 --- a/apps/complaints/models.py +++ b/apps/complaints/models.py @@ -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})" diff --git a/apps/complaints/signals.py b/apps/complaints/signals.py index 74589f5..8fbdfa2 100644 --- a/apps/complaints/signals.py +++ b/apps/complaints/signals.py @@ -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}" diff --git a/apps/complaints/tasks.py b/apps/complaints/tasks.py index c8222e2..5121172 100644 --- a/apps/complaints/tasks.py +++ b/apps/complaints/tasks.py @@ -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) diff --git a/apps/complaints/templatetags/__init__.py b/apps/complaints/templatetags/__init__.py new file mode 100644 index 0000000..2fd22dc --- /dev/null +++ b/apps/complaints/templatetags/__init__.py @@ -0,0 +1,3 @@ +""" +Complaints template tags +""" diff --git a/apps/complaints/templatetags/math.py b/apps/complaints/templatetags/math.py new file mode 100644 index 0000000..e8b136a --- /dev/null +++ b/apps/complaints/templatetags/math.py @@ -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 diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index 54b0fa8..3e7076c 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -12,11 +12,12 @@ 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, Physician +from apps.organizations.models import Department, Hospital, Staff from .models import ( Complaint, ComplaintAttachment, + ComplaintCategory, ComplaintStatus, ComplaintUpdate, Inquiry, @@ -29,7 +30,7 @@ from .models import ( def complaint_list(request): """ Complaints list view with advanced filters and pagination. - + Features: - Server-side pagination - Advanced filters (status, severity, priority, hospital, department, etc.) @@ -39,10 +40,10 @@ def complaint_list(request): """ # Base queryset with optimizations queryset = Complaint.objects.select_related( - 'patient', 'hospital', 'department', 'physician', + 'patient', 'hospital', 'department', 'staff', 'assigned_to', 'resolved_by', 'closed_by' ) - + # Apply RBAC filters user = request.user if user.is_px_admin(): @@ -55,48 +56,48 @@ def complaint_list(request): queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() - + # Apply filters from request 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) - + priority_filter = request.GET.get('priority') if priority_filter: queryset = queryset.filter(priority=priority_filter) - + category_filter = request.GET.get('category') if category_filter: queryset = queryset.filter(category=category_filter) - + source_filter = request.GET.get('source') if source_filter: queryset = queryset.filter(source=source_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) - + overdue_filter = request.GET.get('is_overdue') if overdue_filter == 'true': queryset = queryset.filter(is_overdue=True) - + # Search search_query = request.GET.get('search') if search_query: @@ -107,40 +108,40 @@ def complaint_list(request): Q(patient__first_name__icontains=search_query) | Q(patient__last_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(), @@ -148,7 +149,7 @@ def complaint_list(request): 'in_progress': queryset.filter(status=ComplaintStatus.IN_PROGRESS).count(), 'overdue': queryset.filter(is_overdue=True).count(), } - + context = { 'page_obj': page_obj, 'complaints': page_obj.object_list, @@ -159,7 +160,7 @@ def complaint_list(request): 'status_choices': ComplaintStatus.choices, 'filters': request.GET, } - + return render(request, 'complaints/complaint_list.html', context) @@ -167,7 +168,7 @@ def complaint_list(request): def complaint_detail(request, pk): """ Complaint detail view with timeline, attachments, and actions. - + Features: - Full complaint details - Timeline of all updates @@ -178,7 +179,7 @@ def complaint_detail(request, pk): """ complaint = get_object_or_404( Complaint.objects.select_related( - 'patient', 'hospital', 'department', 'physician', + 'patient', 'hospital', 'department', 'staff', 'assigned_to', 'resolved_by', 'closed_by', 'resolution_survey' ).prefetch_related( 'attachments', @@ -186,7 +187,7 @@ def complaint_detail(request, pk): ), pk=pk ) - + # Check access user = request.user if not user.is_px_admin(): @@ -199,13 +200,13 @@ def complaint_detail(request, pk): elif user.hospital and complaint.hospital != user.hospital: messages.error(request, "You don't have permission to view this complaint.") return redirect('complaints:complaint_list') - + # Get timeline (updates) timeline = complaint.updates.all().order_by('-created_at') - + # Get attachments attachments = complaint.attachments.all().order_by('-created_at') - + # Get related PX actions (using ContentType since PXAction uses GenericForeignKey) from django.contrib.contenttypes.models import ContentType from apps.px_action_center.models import PXAction @@ -214,15 +215,15 @@ def complaint_detail(request, pk): content_type=complaint_ct, object_id=complaint.id ).order_by('-created_at') - + # Get assignable users assignable_users = User.objects.filter(is_active=True) if complaint.hospital: assignable_users = assignable_users.filter(hospital=complaint.hospital) - + # Check if overdue complaint.check_overdue() - + context = { 'complaint': complaint, 'timeline': timeline, @@ -232,55 +233,70 @@ def complaint_detail(request, pk): 'status_choices': ComplaintStatus.choices, 'can_edit': user.is_px_admin() or user.is_hospital_admin(), } - + return render(request, 'complaints/complaint_detail.html', context) @login_required @require_http_methods(["GET", "POST"]) def complaint_create(request): - """Create new complaint""" + """Create new complaint with AI-powered classification""" if request.method == 'POST': # Handle form submission try: from apps.organizations.models import Patient - + # Get form data patient_id = request.POST.get('patient_id') hospital_id = request.POST.get('hospital_id') department_id = request.POST.get('department_id', None) - physician_id = request.POST.get('physician_id', None) - - title = request.POST.get('title') + staff_id = request.POST.get('staff_id', None) + description = request.POST.get('description') - category = request.POST.get('category') - subcategory = request.POST.get('subcategory', '') - priority = request.POST.get('priority') - severity = request.POST.get('severity') + category_id = request.POST.get('category') + subcategory_id = request.POST.get('subcategory', '') source = request.POST.get('source') encounter_id = request.POST.get('encounter_id', '') - + # Validate required fields - if not all([patient_id, hospital_id, title, description, category, priority, severity, source]): + if not all([patient_id, hospital_id, description, category_id, source]): messages.error(request, "Please fill in all required fields.") return redirect('complaints:complaint_create') - - # Create complaint + + # Get category and subcategory objects + category = ComplaintCategory.objects.get(id=category_id) + subcategory_obj = None + if subcategory_id: + subcategory_obj = ComplaintCategory.objects.get(id=subcategory_id) + + # Create complaint with AI defaults complaint = Complaint.objects.create( patient_id=patient_id, hospital_id=hospital_id, department_id=department_id if department_id else None, - physician_id=physician_id if physician_id else None, - title=title, + staff_id=staff_id if staff_id else None, + title='Complaint', # AI will generate title description=description, category=category, - subcategory=subcategory, - priority=priority, - severity=severity, + subcategory=subcategory_obj.code if subcategory_obj else '', + priority='medium', # AI will update + severity='medium', # AI will update source=source, encounter_id=encounter_id, ) - + + # Create initial update + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='note', + message='Complaint created. AI analysis running in background.', + created_by=request.user + ) + + # Trigger AI analysis in the background using Celery + from apps.complaints.tasks import analyze_complaint_with_ai + analyze_complaint_with_ai.delay(str(complaint.id)) + # Log audit AuditService.log_event( event_type='complaint_created', @@ -288,28 +304,32 @@ def complaint_create(request): user=request.user, content_object=complaint, metadata={ - 'category': complaint.category, + 'category': category.name_en, 'severity': complaint.severity, - 'patient_mrn': complaint.patient.mrn + 'patient_mrn': complaint.patient.mrn, + 'ai_analysis_pending': True } ) - - messages.success(request, f"Complaint #{complaint.id} created successfully.") + + messages.success(request, f"Complaint #{complaint.id} created successfully. AI is analyzing and classifying the complaint.") return redirect('complaints:complaint_detail', pk=complaint.id) - + + except ComplaintCategory.DoesNotExist: + messages.error(request, "Selected category not found.") + return redirect('complaints:complaint_create') except Exception as e: messages.error(request, f"Error creating complaint: {str(e)}") return redirect('complaints:complaint_create') - + # 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, 'complaints/complaint_form.html', context) @@ -318,24 +338,24 @@ def complaint_create(request): def complaint_assign(request, pk): """Assign complaint to user""" complaint = get_object_or_404(Complaint, pk=pk) - + # 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 complaints.") return redirect('complaints:complaint_detail', pk=pk) - + user_id = request.POST.get('user_id') if not user_id: messages.error(request, "Please select a user to assign.") return redirect('complaints:complaint_detail', pk=pk) - + try: assignee = User.objects.get(id=user_id) complaint.assigned_to = assignee complaint.assigned_at = timezone.now() complaint.save(update_fields=['assigned_to', 'assigned_at']) - + # Create update ComplaintUpdate.objects.create( complaint=complaint, @@ -343,7 +363,7 @@ def complaint_assign(request, pk): message=f"Assigned to {assignee.get_full_name()}", created_by=request.user ) - + # Log audit AuditService.log_event( event_type='assignment', @@ -351,12 +371,12 @@ def complaint_assign(request, pk): user=request.user, content_object=complaint ) - + messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.") - + except User.DoesNotExist: messages.error(request, "User not found.") - + return redirect('complaints:complaint_detail', pk=pk) @@ -365,23 +385,23 @@ def complaint_assign(request, pk): def complaint_change_status(request, pk): """Change complaint status""" complaint = get_object_or_404(Complaint, pk=pk) - + # 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 complaint status.") return redirect('complaints:complaint_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('complaints:complaint_detail', pk=pk) - + old_status = complaint.status complaint.status = new_status - + # Handle status-specific logic if new_status == ComplaintStatus.RESOLVED: complaint.resolved_at = timezone.now() @@ -389,13 +409,13 @@ def complaint_change_status(request, pk): elif new_status == ComplaintStatus.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, @@ -405,7 +425,7 @@ def complaint_change_status(request, pk): old_status=old_status, new_status=new_status ) - + # Log audit AuditService.log_event( event_type='status_change', @@ -414,7 +434,7 @@ def complaint_change_status(request, pk): content_object=complaint, metadata={'old_status': old_status, 'new_status': new_status} ) - + messages.success(request, f"Complaint status changed to {new_status}.") return redirect('complaints:complaint_detail', pk=pk) @@ -424,12 +444,12 @@ def complaint_change_status(request, pk): def complaint_add_note(request, pk): """Add note to complaint""" complaint = get_object_or_404(Complaint, pk=pk) - + note = request.POST.get('note') if not note: messages.error(request, "Please enter a note.") return redirect('complaints:complaint_detail', pk=pk) - + # Create update ComplaintUpdate.objects.create( complaint=complaint, @@ -437,7 +457,7 @@ def complaint_add_note(request, pk): message=note, created_by=request.user ) - + messages.success(request, "Note added successfully.") return redirect('complaints:complaint_detail', pk=pk) @@ -447,19 +467,19 @@ def complaint_add_note(request, pk): def complaint_escalate(request, pk): """Escalate complaint""" complaint = get_object_or_404(Complaint, pk=pk) - + # 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 escalate complaints.") return redirect('complaints:complaint_detail', pk=pk) - + reason = request.POST.get('reason', '') - + # Mark as escalated complaint.escalated_at = timezone.now() complaint.save(update_fields=['escalated_at']) - + # Create update ComplaintUpdate.objects.create( complaint=complaint, @@ -467,7 +487,7 @@ def complaint_escalate(request, pk): message=f"Complaint escalated. Reason: {reason}", created_by=request.user ) - + # Log audit AuditService.log_event( event_type='escalation', @@ -476,7 +496,7 @@ def complaint_escalate(request, pk): content_object=complaint, metadata={'reason': reason} ) - + messages.success(request, "Complaint escalated successfully.") return redirect('complaints:complaint_detail', pk=pk) @@ -485,13 +505,13 @@ def complaint_escalate(request, pk): def complaint_export_csv(request): """Export complaints to CSV""" from apps.complaints.utils import export_complaints_csv - + # Get filtered queryset (reuse list view logic) queryset = Complaint.objects.select_related( - 'patient', 'hospital', 'department', 'physician', + 'patient', 'hospital', 'department', 'staff', 'assigned_to', 'resolved_by', 'closed_by' ) - + # Apply RBAC filters user = request.user if user.is_px_admin(): @@ -504,32 +524,32 @@ def complaint_export_csv(request): queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() - + # Apply filters from request 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) - + 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) - + overdue_filter = request.GET.get('is_overdue') if overdue_filter == 'true': queryset = queryset.filter(is_overdue=True) - + search_query = request.GET.get('search') if search_query: queryset = queryset.filter( @@ -537,7 +557,7 @@ def complaint_export_csv(request): Q(description__icontains=search_query) | Q(patient__mrn__icontains=search_query) ) - + return export_complaints_csv(queryset, request.GET.dict()) @@ -545,13 +565,13 @@ def complaint_export_csv(request): def complaint_export_excel(request): """Export complaints to Excel""" from apps.complaints.utils import export_complaints_excel - + # Get filtered queryset (same as CSV) queryset = Complaint.objects.select_related( - 'patient', 'hospital', 'department', 'physician', + 'patient', 'hospital', 'department', 'staff', 'assigned_to', 'resolved_by', 'closed_by' ) - + # Apply RBAC filters user = request.user if user.is_px_admin(): @@ -564,32 +584,32 @@ def complaint_export_excel(request): queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() - + # Apply filters from request 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) - + 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) - + overdue_filter = request.GET.get('is_overdue') if overdue_filter == 'true': queryset = queryset.filter(is_overdue=True) - + search_query = request.GET.get('search') if search_query: queryset = queryset.filter( @@ -597,7 +617,7 @@ def complaint_export_excel(request): Q(description__icontains=search_query) | Q(patient__mrn__icontains=search_query) ) - + return export_complaints_excel(queryset, request.GET.dict()) @@ -607,26 +627,26 @@ def complaint_bulk_assign(request): """Bulk assign complaints""" from apps.complaints.utils import bulk_assign_complaints import json - + # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) - + try: data = json.loads(request.body) complaint_ids = data.get('complaint_ids', []) user_id = data.get('user_id') - + if not complaint_ids or not user_id: return JsonResponse({'success': False, 'error': 'Missing required fields'}, status=400) - + result = bulk_assign_complaints(complaint_ids, user_id, request.user) - + if result['success']: messages.success(request, f"Successfully assigned {result['success_count']} complaints.") - + return JsonResponse(result) - + except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) except Exception as e: @@ -639,27 +659,27 @@ def complaint_bulk_status(request): """Bulk change complaint status""" from apps.complaints.utils import bulk_change_status import json - + # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) - + try: data = json.loads(request.body) complaint_ids = data.get('complaint_ids', []) new_status = data.get('status') note = data.get('note', '') - + if not complaint_ids or not new_status: return JsonResponse({'success': False, 'error': 'Missing required fields'}, status=400) - + result = bulk_change_status(complaint_ids, new_status, request.user, note) - + if result['success']: messages.success(request, f"Successfully updated {result['success_count']} complaints.") - + return JsonResponse(result) - + except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) except Exception as e: @@ -672,26 +692,26 @@ def complaint_bulk_escalate(request): """Bulk escalate complaints""" from apps.complaints.utils import bulk_escalate_complaints import json - + # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) - + try: data = json.loads(request.body) complaint_ids = data.get('complaint_ids', []) reason = data.get('reason', '') - + if not complaint_ids: return JsonResponse({'success': False, 'error': 'No complaints selected'}, status=400) - + result = bulk_escalate_complaints(complaint_ids, request.user, reason) - + if result['success']: messages.success(request, f"Successfully escalated {result['success_count']} complaints.") - + return JsonResponse(result) - + except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) except Exception as e: @@ -708,12 +728,12 @@ def inquiry_list(request): Inquiries list view with filters and pagination. """ from .models import Inquiry - + # Base queryset with optimizations queryset = Inquiry.objects.select_related( 'patient', 'hospital', 'department', 'assigned_to', 'responded_by' ) - + # Apply RBAC filters user = request.user if user.is_px_admin(): @@ -726,24 +746,24 @@ 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) - + department_filter = request.GET.get('department') if department_filter: queryset = queryset.filter(department_id=department_filter) - + # Search search_query = request.GET.get('search') if search_query: @@ -753,26 +773,26 @@ def inquiry_list(request): Q(contact_name__icontains=search_query) | Q(contact_email__icontains=search_query) ) - + # 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) - + # Statistics stats = { 'total': queryset.count(), @@ -780,7 +800,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, @@ -789,13 +809,14 @@ def inquiry_list(request): 'departments': departments, 'filters': request.GET, } - + return render(request, 'complaints/inquiry_list.html', context) @login_required def inquiry_detail(request, pk): """ +<<<<<<< HEAD Inquiry detail view with timeline and attachments. Features: @@ -804,6 +825,12 @@ def inquiry_detail(request, pk): - Attachments management - Workflow actions (assign, status change, add note, respond) """ +======= + Inquiry detail view. + """ + from .models import Inquiry + +>>>>>>> 12310a5 (update complain and add ai and sentiment analysis) inquiry = get_object_or_404( Inquiry.objects.select_related( 'patient', 'hospital', 'department', 'assigned_to', 'responded_by' @@ -813,7 +840,7 @@ def inquiry_detail(request, pk): ), pk=pk ) - + # Check access user = request.user if not user.is_px_admin(): @@ -823,6 +850,7 @@ def inquiry_detail(request, pk): elif user.hospital and inquiry.hospital != user.hospital: messages.error(request, "You don't have permission to view this inquiry.") return redirect('complaints:inquiry_list') +<<<<<<< HEAD # Get timeline (updates) timeline = inquiry.updates.all().order_by('-created_at') @@ -830,10 +858,14 @@ def inquiry_detail(request, pk): # Get attachments attachments = inquiry.attachments.all().order_by('-created_at') +======= + +>>>>>>> 12310a5 (update complain and add ai and sentiment analysis) # Get assignable users assignable_users = User.objects.filter(is_active=True) if inquiry.hospital: assignable_users = assignable_users.filter(hospital=inquiry.hospital) +<<<<<<< HEAD # Status choices for the form status_choices = [ @@ -843,6 +875,9 @@ def inquiry_detail(request, pk): ('closed', 'Closed'), ] +======= + +>>>>>>> 12310a5 (update complain and add ai and sentiment analysis) context = { 'inquiry': inquiry, 'timeline': timeline, @@ -851,7 +886,7 @@ def inquiry_detail(request, pk): 'status_choices': status_choices, 'can_edit': user.is_px_admin() or user.is_hospital_admin(), } - + return render(request, 'complaints/inquiry_detail.html', context) @@ -861,28 +896,28 @@ def inquiry_create(request): """Create new inquiry""" from .models import Inquiry from apps.organizations.models import Patient - + if request.method == 'POST': try: # Get form data 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', '') - + # Validate required fields if not all([hospital_id, subject, message, category]): messages.error(request, "Please fill in all required fields.") return redirect('complaints:inquiry_create') - + # Create inquiry inquiry = Inquiry.objects.create( patient_id=patient_id if patient_id else None, @@ -895,7 +930,7 @@ def inquiry_create(request): contact_phone=contact_phone, contact_email=contact_email, ) - + # Log audit AuditService.log_event( event_type='inquiry_created', @@ -904,23 +939,23 @@ def inquiry_create(request): content_object=inquiry, metadata={'category': inquiry.category} ) - + messages.success(request, f"Inquiry #{inquiry.id} created successfully.") return redirect('complaints:inquiry_detail', pk=inquiry.id) - + except Exception as e: messages.error(request, f"Error creating inquiry: {str(e)}") return redirect('complaints:inquiry_create') - + # 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, 'complaints/inquiry_form.html', context) @@ -1056,25 +1091,26 @@ def inquiry_add_note(request, pk): def inquiry_respond(request, pk): """Respond to inquiry""" from .models import Inquiry - + inquiry = get_object_or_404(Inquiry, pk=pk) - + # 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 respond to inquiries.") return redirect('complaints:inquiry_detail', pk=pk) - + response = request.POST.get('response') if not response: messages.error(request, "Please enter a response.") return redirect('complaints:inquiry_detail', pk=pk) - + inquiry.response = response inquiry.responded_at = timezone.now() inquiry.responded_by = request.user inquiry.status = 'resolved' inquiry.save() +<<<<<<< HEAD # Create update InquiryUpdate.objects.create( @@ -1084,6 +1120,9 @@ def inquiry_respond(request, pk): created_by=request.user ) +======= + +>>>>>>> 12310a5 (update complain and add ai and sentiment analysis) # Log audit AuditService.log_event( event_type='inquiry_responded', @@ -1091,7 +1130,7 @@ def inquiry_respond(request, pk): user=request.user, content_object=inquiry ) - + messages.success(request, "Response sent successfully.") return redirect('complaints:inquiry_detail', pk=pk) @@ -1106,17 +1145,17 @@ def complaints_analytics(request): Complaints analytics dashboard. """ from .analytics import ComplaintAnalytics - + user = request.user hospital = None - + # Apply RBAC if not user.is_px_admin() and user.hospital: hospital = user.hospital - + # Get date range from request date_range = int(request.GET.get('date_range', 30)) - + # Get analytics data dashboard_summary = ComplaintAnalytics.get_dashboard_summary(hospital) trends = ComplaintAnalytics.get_complaint_trends(hospital, date_range) @@ -1124,7 +1163,7 @@ def complaints_analytics(request): resolution_rate = ComplaintAnalytics.get_resolution_rate(hospital, date_range) top_categories = ComplaintAnalytics.get_top_categories(hospital, date_range) overdue_complaints = ComplaintAnalytics.get_overdue_complaints(hospital) - + context = { 'dashboard_summary': dashboard_summary, 'trends': trends, @@ -1134,12 +1173,250 @@ def complaints_analytics(request): 'overdue_complaints': overdue_complaints, 'date_range': date_range, } - + return render(request, 'complaints/analytics.html', context) # ============================================================================ -# AJAX/API HELPERS +# PUBLIC COMPLAINT FORM (No Authentication Required) +# ============================================================================ + +def public_complaint_submit(request): + """ + Public complaint submission form (accessible without login). + Handles both GET (show form) and POST (submit complaint). + + Key changes for AI-powered classification: + - Simplified form with only 5 required fields: name, email, phone, hospital, description + - AI generates: title, category, subcategory, department, severity, priority + - Patient lookup removed - contact info stored directly + """ + if request.method == 'POST': + try: + # Get form data from simplified form + name = request.POST.get('name') + email = request.POST.get('email') + phone = request.POST.get('phone') + hospital_id = request.POST.get('hospital') + category_id = request.POST.get('category') + subcategory_id = request.POST.get('subcategory') + description = request.POST.get('description') + + # Validate required fields + errors = [] + if not name: + errors.append("Name is required") + if not email: + errors.append("Email is required") + if not phone: + errors.append("Phone is required") + if not hospital_id: + errors.append("Hospital is required") + if not category_id: + errors.append("Category is required") + if not description: + errors.append("Description is required") + + if errors: + if request.headers.get('x-requested-with') == 'XMLHttpRequest': + return JsonResponse({ + 'success': False, + 'errors': errors + }, status=400) + else: + messages.error(request, "Please fill in all required fields.") + return render(request, 'complaints/public_complaint_form.html', { + 'hospitals': Hospital.objects.filter(status='active').order_by('name'), + }) + + # Get hospital + hospital = Hospital.objects.get(id=hospital_id) + + # Get category and subcategory + from .models import ComplaintCategory + category = ComplaintCategory.objects.get(id=category_id) + subcategory = None + if subcategory_id: + subcategory = ComplaintCategory.objects.get(id=subcategory_id) + + # Generate unique reference number: CMP-YYYYMMDD-XXXXX + import uuid + from datetime import datetime + today = datetime.now().strftime('%Y%m%d') + random_suffix = str(uuid.uuid4().int)[:6] + reference_number = f"CMP-{today}-{random_suffix}" + + # Create complaint with user-selected category/subcategory + complaint = Complaint.objects.create( + patient=None, # No patient record for public submissions + hospital=hospital, + department=None, # AI will determine this + title='Complaint', # AI will generate title + description=description, + category=category, # category is ForeignKey, assign the instance + subcategory=subcategory.code if subcategory else '', # subcategory is CharField, assign the code + severity='medium', # Default, AI will update + priority='medium', # Default, AI will update + source='public', # Mark as public submission + status='open', # Start as open + reference_number=reference_number, + # Contact info from simplified form + contact_name=name, + contact_phone=phone, + contact_email=email, + ) + + # Create initial update + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='note', + message='Complaint submitted via public form. AI analysis running in background.', + ) + + # Trigger AI analysis in the background using Celery + from .tasks import analyze_complaint_with_ai + analyze_complaint_with_ai.delay(str(complaint.id)) + + # If form was submitted via AJAX, return JSON + if request.headers.get('x-requested-with') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'reference_number': reference_number, + 'message': 'Complaint submitted successfully. AI has analyzed and classified your complaint.' + }) + + # Otherwise, redirect to success page + return redirect('complaints:public_complaint_success', reference=reference_number) + + except Hospital.DoesNotExist: + error_msg = "Selected hospital not found." + if request.headers.get('x-requested-with') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'message': error_msg}, status=400) + messages.error(request, error_msg) + return render(request, 'complaints/public_complaint_form.html', { + 'hospitals': Hospital.objects.filter(status='active').order_by('name'), + }) + except Exception as e: + import traceback + traceback.print_exc() + # If AJAX, return error JSON + if request.headers.get('x-requested-with') == 'XMLHttpRequest': + return JsonResponse({ + 'success': False, + 'message': str(e) + }, status=400) + + # Otherwise, show error message + return render(request, 'complaints/public_complaint_form.html', { + 'hospitals': Hospital.objects.filter(status='active').order_by('name'), + 'error': str(e) + }) + + # GET request - show form + return render(request, 'complaints/public_complaint_form.html', { + 'hospitals': Hospital.objects.filter(status='active').order_by('name'), + }) + + +def public_complaint_success(request, reference): + """ + Success page after public complaint submission. + """ + return render(request, 'complaints/public_complaint_success.html', { + 'reference_number': reference + }) + + +def api_lookup_patient(request): + """ + AJAX endpoint to look up patient by national ID. + No authentication required for public form. + """ + from apps.organizations.models import Patient + + national_id = request.GET.get('national_id') + + if not national_id: + return JsonResponse({'found': False, 'error': 'National ID required'}, status=400) + + try: + patient = Patient.objects.get(national_id=national_id, status='active') + + return JsonResponse({ + 'found': True, + 'mrn': patient.mrn, + 'name': patient.get_full_name(), + 'phone': patient.phone or '', + 'email': patient.email or '', + }) + + except Patient.DoesNotExist: + return JsonResponse({ + 'found': False, + 'message': 'Patient not found' + }) + + +def api_load_departments(request): + """ + AJAX endpoint to load departments for a hospital. + No authentication required for public form. + """ + hospital_id = request.GET.get('hospital_id') + + if not hospital_id: + return JsonResponse({'departments': []}) + + departments = Department.objects.filter( + hospital_id=hospital_id, + status='active' + ).values('id', 'name') + + return JsonResponse({'departments': list(departments)}) + + +def api_load_categories(request): + """ + AJAX endpoint to load complaint categories for a hospital. + Shows hospital-specific categories first, then system-wide categories. + Returns both parent categories and their subcategories with parent_id. + No authentication required for public form. + """ + from .models import ComplaintCategory + + hospital_id = request.GET.get('hospital_id') + + # Build queryset + if hospital_id: + # Return hospital-specific and system-wide categories + # Empty hospitals list = system-wide + categories_queryset = ComplaintCategory.objects.filter( + Q(hospitals__id=hospital_id) | Q(hospitals__isnull=True), + is_active=True + ).distinct().order_by('order', 'name_en') + else: + # Return only system-wide categories (empty hospitals list) + categories_queryset = ComplaintCategory.objects.filter( + hospitals__isnull=True, + is_active=True + ).order_by('order', 'name_en') + + # Get all categories with parent_id and descriptions + categories = categories_queryset.values( + 'id', + 'name_en', + 'name_ar', + 'code', + 'parent_id', + 'description_en', + 'description_ar' + ) + + return JsonResponse({'categories': list(categories)}) + + +# ============================================================================ +# AJAX/API HELPERS (Authentication Required) # ============================================================================ @login_required @@ -1148,47 +1425,46 @@ 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' ).values('id', 'name', 'name_ar') - + return JsonResponse({'departments': list(departments)}) @login_required -def get_physicians_by_department(request): - """Get physicians for a department (AJAX)""" +def get_staff_by_department(request): + """Get staff for a department (AJAX)""" department_id = request.GET.get('department_id') if not department_id: - return JsonResponse({'physicians': []}) - - from apps.organizations.models import Physician - physicians = Physician.objects.filter( + return JsonResponse({'staff': []}) + + staff_members = Staff.objects.filter( department_id=department_id, status='active' - ).values('id', 'first_name', 'last_name') - - return JsonResponse({'physicians': list(physicians)}) + ).values('id', 'first_name', 'last_name', 'staff_type', 'job_title') + + return JsonResponse({'staff': list(staff_members)}) @login_required def search_patients(request): """Search patients by MRN or name (AJAX)""" from apps.organizations.models import Patient - + query = request.GET.get('q', '') if len(query) < 2: return JsonResponse({'patients': []}) - + patients = Patient.objects.filter( Q(mrn__icontains=query) | Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(national_id__icontains=query) )[:10] - + results = [ { 'id': str(p.id), @@ -1199,5 +1475,5 @@ def search_patients(request): } for p in patients ] - + return JsonResponse({'patients': results}) diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py index 778df6a..ab5e987 100644 --- a/apps/complaints/urls.py +++ b/apps/complaints/urls.py @@ -20,16 +20,16 @@ urlpatterns = [ path('/change-status/', ui_views.complaint_change_status, name='complaint_change_status'), path('/add-note/', ui_views.complaint_add_note, name='complaint_add_note'), path('/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//change-status/', ui_views.inquiry_change_status, name='inquiry_change_status'), path('inquiries//add-note/', ui_views.inquiry_add_note, name='inquiry_add_note'), path('inquiries//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//', 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)), ] diff --git a/apps/complaints/views.py b/apps/complaints/views.py index ec0de20..e8da8d4 100644 --- a/apps/complaints/views.py +++ b/apps/complaints/views.py @@ -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'}) diff --git a/apps/core/ai_service.py b/apps/core/ai_service.py new file mode 100644 index 0000000..ba81eac --- /dev/null +++ b/apps/core/ai_service.py @@ -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() diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index 3efebe9..c4c2f91 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -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, } diff --git a/apps/core/managers.py b/apps/core/managers.py new file mode 100644 index 0000000..fe19510 --- /dev/null +++ b/apps/core/managers.py @@ -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) diff --git a/apps/core/middleware.py b/apps/core/middleware.py new file mode 100644 index 0000000..3bbc276 --- /dev/null +++ b/apps/core/middleware.py @@ -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 diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py index 043bf9d..6f592d2 100644 --- a/apps/core/migrations/0001_initial.py +++ b/apps/core/migrations/0001_initial.py @@ -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 diff --git a/apps/core/mixins.py b/apps/core/mixins.py new file mode 100644 index 0000000..8628116 --- /dev/null +++ b/apps/core/mixins.py @@ -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 diff --git a/apps/core/models.py b/apps/core/models.py index 50cade0..69b12b4 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -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']), + ] diff --git a/apps/core/templatetags/__init__.py b/apps/core/templatetags/__init__.py new file mode 100644 index 0000000..2b18d3e --- /dev/null +++ b/apps/core/templatetags/__init__.py @@ -0,0 +1 @@ +# Template tags for the core app diff --git a/apps/core/templatetags/hospital_filters.py b/apps/core/templatetags/hospital_filters.py new file mode 100644 index 0000000..bfead23 --- /dev/null +++ b/apps/core/templatetags/hospital_filters.py @@ -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') diff --git a/apps/core/urls.py b/apps/core/urls.py index 97bcaa6..c275317 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -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) diff --git a/apps/core/views.py b/apps/core/views.py index 0f5b888..d77e6a0 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -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) diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index 6c2ed01..28554f6 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -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( diff --git a/apps/feedback/forms.py b/apps/feedback/forms.py index c2e5968..c05c0cc 100644 --- a/apps/feedback/forms.py +++ b/apps/feedback/forms.py @@ -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={ diff --git a/apps/feedback/migrations/0001_initial.py b/apps/feedback/migrations/0001_initial.py index 12d1d76..0897612 100644 --- a/apps/feedback/migrations/0001_initial.py +++ b/apps/feedback/migrations/0001_initial.py @@ -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'), - ), ] diff --git a/apps/feedback/migrations/0002_add_survey_linkage.py b/apps/feedback/migrations/0002_add_survey_linkage.py deleted file mode 100644 index 7c41696..0000000 --- a/apps/feedback/migrations/0002_add_survey_linkage.py +++ /dev/null @@ -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), - ), - ] diff --git a/apps/feedback/migrations/0002_initial.py b/apps/feedback/migrations/0002_initial.py new file mode 100644 index 0000000..c8da2bd --- /dev/null +++ b/apps/feedback/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/apps/feedback/models.py b/apps/feedback/models.py index edc5ad7..99e1606 100644 --- a/apps/feedback/models.py +++ b/apps/feedback/models.py @@ -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')}" diff --git a/apps/feedback/views.py b/apps/feedback/views.py index f7c468f..8c9baec 100644 --- a/apps/feedback/views.py +++ b/apps/feedback/views.py @@ -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) diff --git a/apps/integrations/migrations/0001_initial.py b/apps/integrations/migrations/0001_initial.py index 5a1ccad..313675a 100644 --- a/apps/integrations/migrations/0001_initial.py +++ b/apps/integrations/migrations/0001_initial.py @@ -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 diff --git a/apps/integrations/tasks.py b/apps/integrations/tasks.py index 84d245e..1e11c14 100644 --- a/apps/integrations/tasks.py +++ b/apps/integrations/tasks.py @@ -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} diff --git a/apps/journeys/admin.py b/apps/journeys/admin.py index 6d78613..3f0ca75 100644 --- a/apps/journeys/admin.py +++ b/apps/journeys/admin.py @@ -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' diff --git a/apps/journeys/migrations/0001_initial.py b/apps/journeys/migrations/0001_initial.py index aea7c23..b32bb12 100644 --- a/apps/journeys/migrations/0001_initial.py +++ b/apps/journeys/migrations/0001_initial.py @@ -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'], diff --git a/apps/journeys/migrations/0002_initial.py b/apps/journeys/migrations/0002_initial.py index 19e8d6e..2bc527f 100644 --- a/apps/journeys/migrations/0002_initial.py +++ b/apps/journeys/migrations/0002_initial.py @@ -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 diff --git a/apps/journeys/models.py b/apps/journeys/models.py index 6e4a5b7..098ad21 100644 --- a/apps/journeys/models.py +++ b/apps/journeys/models.py @@ -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: diff --git a/apps/journeys/views.py b/apps/journeys/views.py index f4bb39a..c3fe2e4 100644 --- a/apps/journeys/views.py +++ b/apps/journeys/views.py @@ -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() diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py index 863dd52..36d3cbe 100644 --- a/apps/notifications/migrations/0001_initial.py +++ b/apps/notifications/migrations/0001_initial.py @@ -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 diff --git a/apps/notifications/services.py b/apps/notifications/services.py index 3a47b42..abc710d 100644 --- a/apps/notifications/services.py +++ b/apps/notifications/services.py @@ -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, diff --git a/apps/organizations/admin.py b/apps/organizations/admin.py index 4dbaa5c..d6c5ddc 100644 --- a/apps/organizations/admin.py +++ b/apps/organizations/admin.py @@ -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') diff --git a/apps/organizations/management/commands/create_default_organization.py b/apps/organizations/management/commands/create_default_organization.py new file mode 100644 index 0000000..738e42f --- /dev/null +++ b/apps/organizations/management/commands/create_default_organization.py @@ -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")) diff --git a/apps/organizations/migrations/0001_initial.py b/apps/organizations/migrations/0001_initial.py index 9a52eba..e55baef 100644 --- a/apps/organizations/migrations/0001_initial.py +++ b/apps/organizations/migrations/0001_initial.py @@ -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, }, ), ] diff --git a/apps/organizations/models.py b/apps/organizations/models.py index 04c4f41..763fc3f 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -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 diff --git a/apps/organizations/serializers.py b/apps/organizations/serializers.py index a3e5c0e..b05146f 100644 --- a/apps/organizations/serializers.py +++ b/apps/organizations/serializers.py @@ -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 = [ diff --git a/apps/organizations/ui_views.py b/apps/organizations/ui_views.py index c08f781..091afc6 100644 --- a/apps/organizations/ui_views.py +++ b/apps/organizations/ui_views.py @@ -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) diff --git a/apps/organizations/urls.py b/apps/organizations/urls.py index 43cb1cc..d6ee824 100644 --- a/apps/organizations/urls.py +++ b/apps/organizations/urls.py @@ -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//', 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)), ] diff --git a/apps/organizations/views.py b/apps/organizations/views.py index 0eed0be..58efab7 100644 --- a/apps/organizations/views.py +++ b/apps/organizations/views.py @@ -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 diff --git a/apps/physicians/admin.py b/apps/physicians/admin.py index 5b21f7a..fad0755 100644 --- a/apps/physicians/admin.py +++ b/apps/physicians/admin.py @@ -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') diff --git a/apps/physicians/migrations/0001_initial.py b/apps/physicians/migrations/0001_initial.py index 1a89ac4..b09a2bc 100644 --- a/apps/physicians/migrations/0001_initial.py +++ b/apps/physicians/migrations/0001_initial.py @@ -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')}, }, ), ] diff --git a/apps/physicians/models.py b/apps/physicians/models.py index 5da7efb..f015b64 100644 --- a/apps/physicians/models.py +++ b/apps/physicians/models.py @@ -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}" diff --git a/apps/physicians/serializers.py b/apps/physicians/serializers.py index 10a82d0..b39c5cb 100644 --- a/apps/physicians/serializers.py +++ b/apps/physicians/serializers.py @@ -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 = [ diff --git a/apps/physicians/tasks.py b/apps/physicians/tasks.py index bc77afe..1fada04 100644 --- a/apps/physicians/tasks.py +++ b/apps/physicians/tasks.py @@ -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, diff --git a/apps/physicians/ui_views.py b/apps/physicians/ui_views.py index 3912802..bce8c68 100644 --- a/apps/physicians/ui_views.py +++ b/apps/physicians/ui_views.py @@ -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) diff --git a/apps/physicians/views.py b/apps/physicians/views.py index 9810f70..e38fb15 100644 --- a/apps/physicians/views.py +++ b/apps/physicians/views.py @@ -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, diff --git a/apps/projects/migrations/0001_initial.py b/apps/projects/migrations/0001_initial.py index 927e969..0bb7c78 100644 --- a/apps/projects/migrations/0001_initial.py +++ b/apps/projects/migrations/0001_initial.py @@ -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'), - ), ] diff --git a/apps/projects/migrations/0002_initial.py b/apps/projects/migrations/0002_initial.py new file mode 100644 index 0000000..a18d597 --- /dev/null +++ b/apps/projects/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/apps/px_action_center/migrations/0001_initial.py b/apps/px_action_center/migrations/0001_initial.py index 8cabc77..ed007b2 100644 --- a/apps/px_action_center/migrations/0001_initial.py +++ b/apps/px_action_center/migrations/0001_initial.py @@ -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 diff --git a/apps/social/migrations/0001_initial.py b/apps/social/migrations/0001_initial.py index 3ace18b..0e1149b 100644 --- a/apps/social/migrations/0001_initial.py +++ b/apps/social/migrations/0001_initial.py @@ -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 diff --git a/apps/surveys/migrations/0001_initial.py b/apps/surveys/migrations/0001_initial.py index adcee6b..ad76f54 100644 --- a/apps/surveys/migrations/0001_initial.py +++ b/apps/surveys/migrations/0001_initial.py @@ -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'), + ), ] diff --git a/apps/surveys/migrations/0002_surveyquestion_surveyresponse_and_more.py b/apps/surveys/migrations/0002_surveyquestion_surveyresponse_and_more.py deleted file mode 100644 index 99edf11..0000000 --- a/apps/surveys/migrations/0002_surveyquestion_surveyresponse_and_more.py +++ /dev/null @@ -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')}, - ), - ] diff --git a/apps/surveys/migrations/0003_add_survey_linkage.py b/apps/surveys/migrations/0003_add_survey_linkage.py deleted file mode 100644 index f229052..0000000 --- a/apps/surveys/migrations/0003_add_survey_linkage.py +++ /dev/null @@ -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), - ), - ] diff --git a/apps/surveys/models.py b/apps/surveys/models.py index 0bc4f0a..31a7a7b 100644 --- a/apps/surveys/models.py +++ b/apps/surveys/models.py @@ -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]}" diff --git a/apps/surveys/views.py b/apps/surveys/views.py index 3333955..a1ba267 100644 --- a/apps/surveys/views.py +++ b/apps/surveys/views.py @@ -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[^/.]+)/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 diff --git a/config/settings/base.py b/config/settings/base.py index 54588e4..f43aba5 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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' diff --git a/docs/BILINGUAL_AI_ANALYSIS.md b/docs/BILINGUAL_AI_ANALYSIS.md new file mode 100644 index 0000000..165e3bc --- /dev/null +++ b/docs/BILINGUAL_AI_ANALYSIS.md @@ -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 + +

{{ complaint.title_en }}

+

{{ complaint.short_description_en }}

+ + +

{{ complaint.title_ar }}

+

{{ complaint.short_description_ar }}

+ + +{% if LANGUAGE_CODE == 'ar' %} +

{{ complaint.title_ar }}

+{% else %} +

{{ complaint.title_en }}

+{% 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 diff --git a/docs/EMOTION_ANALYSIS.md b/docs/EMOTION_ANALYSIS.md new file mode 100644 index 0000000..278e005 --- /dev/null +++ b/docs/EMOTION_ANALYSIS.md @@ -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 +
+
Emotion Analysis
+
+
+ + + Anger + + + + Confidence: 92% + + +
+
+
+ Intensity: 0.85 / 1.0 +
+
+
+``` + +**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 + +
Emotion: {{ complaint.get_emotion_display }}
+
Intensity: {{ complaint.emotion_intensity|floatformat:2 }}
+
Confidence: {{ complaint.emotion_confidence|floatformat:2 }}
+ + + + {{ complaint.get_emotion_display }} + + + +
+
+
+
+``` + +## 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 diff --git a/docs/HOSPITAL_HEADER_DISPLAY.md b/docs/HOSPITAL_HEADER_DISPLAY.md new file mode 100644 index 0000000..52e8ab2 --- /dev/null +++ b/docs/HOSPITAL_HEADER_DISPLAY.md @@ -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 %} +

{{ current_hospital.name }}

+

{{ current_hospital.city }}

+{% endif %} +``` + +### Checking User Role + +```django +{% if is_px_admin %} +

You are a PX Admin with access to all hospitals

+{% else %} +

You are viewing: {{ current_hospital.name }}

+{% 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. diff --git a/docs/ORGANIZATION_MODEL.md b/docs/ORGANIZATION_MODEL.md new file mode 100644 index 0000000..9a5a053 --- /dev/null +++ b/docs/ORGANIZATION_MODEL.md @@ -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` diff --git a/docs/TENANT_AWARE_ROUTING_IMPLEMENTATION.md b/docs/TENANT_AWARE_ROUTING_IMPLEMENTATION.md new file mode 100644 index 0000000..a8a3275 --- /dev/null +++ b/docs/TENANT_AWARE_ROUTING_IMPLEMENTATION.md @@ -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. diff --git a/generate_saudi_data.py b/generate_saudi_data.py index d99a0e2..4cdaad1 100644 --- a/generate_saudi_data.py +++ b/generate_saudi_data.py @@ -43,14 +43,14 @@ from apps.observations.models import ( ObservationSeverity, ObservationStatus, ) -from apps.complaints.models import Complaint, ComplaintUpdate +from apps.complaints.models import Complaint, ComplaintCategory, ComplaintUpdate from apps.journeys.models import ( PatientJourneyInstance, PatientJourneyStageInstance, PatientJourneyStageTemplate, PatientJourneyTemplate, ) -from apps.organizations.models import Department, Hospital, Patient, Physician +from apps.organizations.models import Department, Hospital, Patient, Staff from apps.projects.models import QIProject from apps.px_action_center.models import PXAction from apps.surveys.models import SurveyInstance, SurveyQuestion, SurveyResponse, SurveyTemplate @@ -125,80 +125,68 @@ def clear_existing_data(): print("\n" + "="*60) print("Clearing Existing Data...") print("="*60 + "\n") - + from apps.feedback.models import Feedback, FeedbackAttachment, FeedbackResponse from apps.social.models import SocialMention from apps.callcenter.models import CallCenterInteraction - + # Delete in reverse order of dependencies print("Deleting survey instances...") SurveyResponse.objects.all().delete() SurveyInstance.objects.all().delete() - + print("Deleting journey instances...") PatientJourneyStageInstance.objects.all().delete() PatientJourneyInstance.objects.all().delete() - + print("Deleting PX actions...") PXAction.objects.all().delete() - + print("Deleting QI projects...") QIProject.objects.all().delete() - + print("Deleting social mentions...") SocialMention.objects.all().delete() - + print("Deleting call center interactions...") CallCenterInteraction.objects.all().delete() - + print("Deleting feedback...") FeedbackResponse.objects.all().delete() FeedbackAttachment.objects.all().delete() Feedback.objects.all().delete() - + print("Deleting complaints...") ComplaintUpdate.objects.all().delete() Complaint.objects.all().delete() - + print("Deleting survey templates...") SurveyQuestion.objects.all().delete() SurveyTemplate.objects.all().delete() - + print("Deleting journey templates...") PatientJourneyStageTemplate.objects.all().delete() PatientJourneyTemplate.objects.all().delete() - + print("Deleting patients...") Patient.objects.all().delete() - + print("Deleting physician ratings...") from apps.physicians.models import PhysicianMonthlyRating PhysicianMonthlyRating.objects.all().delete() - - print("Deleting physicians...") - Physician.objects.all().delete() - + + print("Deleting staff...") + Staff.objects.all().delete() + print("Deleting departments...") Department.objects.all().delete() - + print("Deleting hospitals...") Hospital.objects.all().delete() - - print("Deleting appreciation data...") - UserBadge.objects.all().delete() - AppreciationStats.objects.all().delete() - Appreciation.objects.all().delete() - # Categories and Badges are preserved (seeded separately) - - print("Deleting observations...") - ObservationStatusLog.objects.all().delete() - ObservationNote.objects.all().delete() - Observation.objects.all().delete() - # ObservationCategory is preserved (seeded separately) - + print("Deleting users (except superusers)...") User.objects.filter(is_superuser=False).delete() - + print("\n✓ All existing data cleared successfully!\n") @@ -260,20 +248,20 @@ def create_departments(hospitals): return departments -def create_physicians(hospitals, departments): - """Create physicians""" - print("Creating physicians...") - physicians = [] +def create_staff(hospitals, departments): + """Create staff""" + print("Creating staff...") + staff = [] for i in range(50): hospital = random.choice(hospitals) dept = random.choice([d for d in departments if d.hospital == hospital]) - + first_name_ar = random.choice(ARABIC_FIRST_NAMES_MALE) last_name_ar = random.choice(ARABIC_LAST_NAMES) first_name = random.choice(ENGLISH_FIRST_NAMES_MALE) last_name = random.choice(ENGLISH_LAST_NAMES) - - physician, created = Physician.objects.get_or_create( + + staff_member, created = Staff.objects.get_or_create( license_number=f"LIC{random.randint(10000, 99999)}", defaults={ 'first_name': first_name, @@ -283,13 +271,14 @@ def create_physicians(hospitals, departments): 'specialization': random.choice(SPECIALIZATIONS), 'hospital': hospital, 'department': dept, - 'phone': generate_saudi_phone(), + # 'phone': generate_saudi_phone(), 'status': 'active', + 'employee_id': f"EMP{random.randint(10000, 99999)}", } ) - physicians.append(physician) - print(f" Created {len(physicians)} physicians") - return physicians + staff.append(staff_member) + print(f" Created {len(staff)} staff members") + return staff def create_patients(hospitals): @@ -304,10 +293,10 @@ def create_patients(hospitals): else: first_name_ar = random.choice(ARABIC_FIRST_NAMES_FEMALE) first_name = random.choice(ENGLISH_FIRST_NAMES_FEMALE) - + last_name_ar = random.choice(ARABIC_LAST_NAMES) last_name = random.choice(ENGLISH_LAST_NAMES) - + patient, created = Patient.objects.get_or_create( mrn=generate_mrn(), defaults={ @@ -334,11 +323,11 @@ def create_users(hospitals): """Create system users""" print("Creating users...") from django.contrib.auth.models import Group - + # Create or get groups px_admin_group, _ = Group.objects.get_or_create(name='PX Admin') hospital_admin_group, _ = Group.objects.get_or_create(name='Hospital Admin') - + # Create PX Admin px_admin, created = User.objects.get_or_create( username='px_admin', @@ -355,7 +344,7 @@ def create_users(hospitals): px_admin.save() px_admin.groups.add(px_admin_group) print(" Created PX Admin") - + # Create Hospital Admins for hospital in hospitals: user, created = User.objects.get_or_create( @@ -375,23 +364,89 @@ def create_users(hospitals): print(f" Created Hospital Admin for {hospital.name}") -def create_complaints(patients, hospitals, physicians, users): +def create_complaint_categories(hospitals): + """Create complaint categories""" + print("Creating complaint categories...") + + # System-wide categories (hospital=None) + system_categories = [ + {'code': 'CLINICAL', 'name_en': 'Clinical Care', 'name_ar': 'الرعاية السريرية', 'order': 1}, + {'code': 'STAFF', 'name_en': 'Staff Behavior', 'name_ar': 'سلوك الموظفين', 'order': 2}, + {'code': 'FACILITY', 'name_en': 'Facility Issues', 'name_ar': 'مشاكل المرافق', 'order': 3}, + {'code': 'WAIT_TIME', 'name_en': 'Wait Time', 'name_ar': 'وقت الانتظار', 'order': 4}, + {'code': 'BILLING', 'name_en': 'Billing & Insurance', 'name_ar': 'الفوترة والتأمين', 'order': 5}, + {'code': 'COMM', 'name_en': 'Communication', 'name_ar': 'التواصل', 'order': 6}, + ] + + categories = [] + + # Create system-wide categories + for cat_data in system_categories: + category, created = ComplaintCategory.objects.get_or_create( + code=cat_data['code'], + hospital=None, + defaults={ + 'name_en': cat_data['name_en'], + 'name_ar': cat_data['name_ar'], + 'order': cat_data['order'], + 'is_active': True, + } + ) + categories.append(category) + if created: + print(f" Created system-wide category: {category.name_en}") + + # Create hospital-specific categories for each hospital + hospital_specific_categories = [ + {'code': 'ER', 'name_en': 'Emergency Services', 'name_ar': 'خدمات الطوارئ'}, + {'code': 'SURGERY', 'name_en': 'Surgery Department', 'name_ar': 'قسم الجراحة'}, + {'code': 'LAB', 'name_en': 'Laboratory Services', 'name_ar': 'خدمات المختبر'}, + ] + + for hospital in hospitals: + for cat_data in hospital_specific_categories: + category, created = ComplaintCategory.objects.get_or_create( + code=f"{cat_data['code']}_{hospital.code}", + hospital=hospital, + defaults={ + 'name_en': cat_data['name_en'], + 'name_ar': cat_data['name_ar'], + 'order': random.randint(10, 20), + 'is_active': True, + } + ) + categories.append(category) + if created: + print(f" Created hospital category for {hospital.name}: {category.name_en}") + + print(f" Created {len(categories)} complaint categories") + return categories + + +def create_complaints(patients, hospitals, staff, users): """Create sample complaints with 2 years of data""" print("Creating complaints (2 years of data)...") + + # Get available complaint categories + categories = list(ComplaintCategory.objects.filter(is_active=True)) + if not categories: + print(" ERROR: No complaint categories found! Please create categories first.") + return [] + complaints = [] now = timezone.now() - + # Generate complaints over 2 years (730 days) # Average 3-5 complaints per day = ~1200-1800 total for day_offset in range(730): # Random number of complaints per day (0-8, weighted towards 2-4) num_complaints = random.choices([0, 1, 2, 3, 4, 5, 6, 7, 8], weights=[5, 10, 20, 25, 20, 10, 5, 3, 2])[0] - + for _ in range(num_complaints): patient = random.choice(patients) hospital = patient.primary_hospital or random.choice(hospitals) created_date = now - timedelta(days=day_offset, hours=random.randint(0, 23), minutes=random.randint(0, 59)) - + # Status distribution based on age if day_offset < 7: # Last week - more open/in_progress status = random.choices(['open', 'in_progress', 'resolved', 'closed'], weights=[30, 40, 20, 10])[0] @@ -399,26 +454,33 @@ def create_complaints(patients, hospitals, physicians, users): status = random.choices(['open', 'in_progress', 'resolved', 'closed'], weights=[10, 30, 40, 20])[0] else: # Older - mostly resolved/closed status = random.choices(['open', 'in_progress', 'resolved', 'closed'], weights=[2, 5, 30, 63])[0] - + + # Select appropriate category (system-wide or hospital-specific) + hospital_categories = [c for c in categories if c.hospital == hospital] + system_categories_list = [c for c in categories if c.hospital is None] + + # Prefer hospital-specific categories if available, otherwise use system-wide + available_categories = hospital_categories if hospital_categories else system_categories_list + complaint = Complaint.objects.create( patient=patient, hospital=hospital, department=random.choice(hospital.departments.all()) if hospital.departments.exists() else None, - physician=random.choice(physicians) if random.random() > 0.5 else None, + staff=random.choice(staff) if random.random() > 0.5 else None, title=random.choice(COMPLAINT_TITLES), description=f"Detailed description of the complaint. Patient experienced issues during their visit.", - category=random.choice(['clinical_care', 'staff_behavior', 'facility', 'wait_time', 'billing', 'communication']), - priority=random.choice(['low', 'medium', 'high', 'urgent']), + category=random.choice(available_categories), + priority=random.choice(['low', 'medium', 'high']), severity=random.choice(['low', 'medium', 'high', 'critical']), source=random.choice(['patient', 'family', 'survey', 'call_center', 'moh', 'other']), status=status, encounter_id=f"ENC{random.randint(100000, 999999)}", assigned_to=random.choice(users) if random.random() > 0.5 else None, ) - + # Override created_at complaint.created_at = created_date - + # Set resolved/closed dates if applicable if status in ['resolved', 'closed']: complaint.resolved_at = created_date + timedelta(hours=random.randint(24, 168)) @@ -426,10 +488,10 @@ def create_complaints(patients, hospitals, physicians, users): if status == 'closed': complaint.closed_at = complaint.resolved_at + timedelta(hours=random.randint(1, 48)) complaint.closed_by = random.choice(users) - + complaint.save() complaints.append(complaint) - + print(f" Created {len(complaints)} complaints over 2 years") return complaints @@ -438,10 +500,10 @@ def create_inquiries(patients, hospitals, users): """Create inquiries with 2 years of data""" print("Creating inquiries (2 years of data)...") from apps.complaints.models import Inquiry - + inquiries = [] now = timezone.now() - + inquiry_subjects = [ 'Question about appointment scheduling', 'Billing inquiry', @@ -454,17 +516,17 @@ def create_inquiries(patients, hospitals, users): 'Parking information', 'Department location inquiry', ] - + # Generate inquiries over 2 years (730 days) # Average 1-2 inquiries per day = ~500-700 total for day_offset in range(730): num_inquiries = random.choices([0, 1, 2, 3], weights=[30, 40, 25, 5])[0] - + for _ in range(num_inquiries): patient = random.choice(patients) if random.random() > 0.3 else None hospital = (patient.primary_hospital if patient else None) or random.choice(hospitals) created_date = now - timedelta(days=day_offset, hours=random.randint(0, 23), minutes=random.randint(0, 59)) - + # Status distribution based on age if day_offset < 7: # Last week status = random.choices(['open', 'in_progress', 'resolved', 'closed'], weights=[40, 35, 20, 5])[0] @@ -472,7 +534,7 @@ def create_inquiries(patients, hospitals, users): status = random.choices(['open', 'in_progress', 'resolved', 'closed'], weights=[15, 30, 40, 15])[0] else: # Older status = random.choices(['open', 'in_progress', 'resolved', 'closed'], weights=[3, 7, 40, 50])[0] - + inquiry = Inquiry.objects.create( patient=patient, contact_name=f"{random.choice(ENGLISH_FIRST_NAMES_MALE)} {random.choice(ENGLISH_LAST_NAMES)}" if not patient else '', @@ -486,28 +548,28 @@ def create_inquiries(patients, hospitals, users): status=status, assigned_to=random.choice(users) if random.random() > 0.5 else None, ) - + # Override created_at inquiry.created_at = created_date - + # Set response if resolved/closed if status in ['resolved', 'closed']: inquiry.response = "Thank you for your inquiry. We have addressed your question." inquiry.responded_at = created_date + timedelta(hours=random.randint(2, 72)) inquiry.responded_by = random.choice(users) - + inquiry.save() inquiries.append(inquiry) - + print(f" Created {len(inquiries)} inquiries over 2 years") return inquiries -def create_feedback(patients, hospitals, physicians, users): +def create_feedback(patients, hospitals, staff, users): """Create sample feedback""" print("Creating feedback...") from apps.feedback.models import Feedback, FeedbackResponse - + feedback_titles = [ 'Excellent care from Dr. Ahmed', 'Very satisfied with the service', @@ -520,7 +582,7 @@ def create_feedback(patients, hospitals, physicians, users): 'Suggestion for better parking', 'Outstanding nursing care', ] - + feedback_messages = [ 'I had a wonderful experience. The staff was very professional and caring.', 'The doctor took time to explain everything clearly. Very satisfied.', @@ -533,13 +595,13 @@ def create_feedback(patients, hospitals, physicians, users): 'The parking area could be improved with better signage.', 'The nursing staff provided outstanding care during my stay.', ] - + feedbacks = [] for i in range(40): patient = random.choice(patients) hospital = patient.primary_hospital or random.choice(hospitals) is_anonymous = random.random() < 0.2 # 20% anonymous - + feedback = Feedback.objects.create( patient=None if is_anonymous else patient, is_anonymous=is_anonymous, @@ -548,7 +610,7 @@ def create_feedback(patients, hospitals, physicians, users): contact_phone=generate_saudi_phone() if is_anonymous else '', hospital=hospital, department=random.choice(hospital.departments.all()) if hospital.departments.exists() and random.random() > 0.5 else None, - physician=random.choice(physicians) if random.random() > 0.6 else None, + staff=random.choice(staff) if random.random() > 0.6 else None, feedback_type=random.choice(['compliment', 'suggestion', 'general', 'inquiry']), title=random.choice(feedback_titles), message=random.choice(feedback_messages), @@ -563,7 +625,7 @@ def create_feedback(patients, hospitals, physicians, users): is_featured=random.random() < 0.15, # 15% featured requires_follow_up=random.random() < 0.2, # 20% require follow-up ) - + # Add initial response FeedbackResponse.objects.create( feedback=feedback, @@ -572,7 +634,7 @@ def create_feedback(patients, hospitals, physicians, users): created_by=random.choice(users), is_internal=True, ) - + # Add additional responses for some feedback if feedback.status in ['reviewed', 'acknowledged', 'closed'] and random.random() > 0.5: FeedbackResponse.objects.create( @@ -582,9 +644,9 @@ def create_feedback(patients, hospitals, physicians, users): created_by=random.choice(users), is_internal=False, ) - + feedbacks.append(feedback) - + print(f" Created {len(feedbacks)} feedback items") return feedbacks @@ -597,7 +659,7 @@ def create_survey_templates(hospitals): if existing_count > 0: print(f" Survey templates already exist ({existing_count} found), skipping creation...") return - + for hospital in hospitals[:2]: # Create for first 2 hospitals # OPD Survey template = SurveyTemplate.objects.create( @@ -611,7 +673,7 @@ def create_survey_templates(hospitals): negative_threshold=3.0, is_active=True, ) - + # Create questions questions_data = [ {'text': 'How would you rate your overall experience?', 'text_ar': 'كيف تقيم تجربتك الإجمالية؟', 'type': 'rating'}, @@ -619,7 +681,7 @@ def create_survey_templates(hospitals): {'text': 'Was the staff courteous and helpful?', 'text_ar': 'هل كان الموظفون مهذبين ومتعاونين؟', 'type': 'yes_no'}, {'text': 'Any additional comments?', 'text_ar': 'أي تعليقات إضافية؟', 'type': 'textarea'}, ] - + for idx, q_data in enumerate(questions_data): SurveyQuestion.objects.create( survey_template=template, @@ -629,7 +691,7 @@ def create_survey_templates(hospitals): order=idx, is_required=True if idx < 2 else False, ) - + print(f" Created survey template for {hospital.name}") @@ -648,7 +710,7 @@ def create_journey_templates(hospitals): 'is_default': True, } ) - + if created: # Create stages stages_data = [ @@ -657,7 +719,7 @@ def create_journey_templates(hospitals): {'name': 'Laboratory', 'name_ar': 'المختبر', 'code': 'LAB', 'trigger': 'LAB_COMPLETED'}, {'name': 'Pharmacy', 'name_ar': 'الصيدلية', 'code': 'PHARM', 'trigger': 'PHARMACY_DISPENSED'}, ] - + for idx, stage_data in enumerate(stages_data): PatientJourneyStageTemplate.objects.create( journey_template=journey_template, @@ -668,7 +730,7 @@ def create_journey_templates(hospitals): trigger_event_code=stage_data['trigger'], is_active=True, ) - + print(f" Created journey template for {hospital.name}") @@ -681,7 +743,7 @@ def create_qi_projects(hospitals): ('Enhance Medication Safety', 'تعزيز سلامة الأدوية'), ('Streamline Discharge Process', 'تبسيط عملية الخروج'), ] - + projects = [] for hospital in hospitals[:2]: for name_en, name_ar in project_names[:2]: @@ -726,17 +788,17 @@ def create_journey_instances(journey_templates, patients): """Create journey instances""" print("Creating journey instances...") from apps.journeys.models import PatientJourneyTemplate - + templates = PatientJourneyTemplate.objects.filter(is_active=True) if not templates.exists(): print(" No journey templates found, skipping...") return [] - + instances = [] for i in range(20): template = random.choice(templates) patient = random.choice(patients) - + instance = PatientJourneyInstance.objects.create( journey_template=template, patient=patient, @@ -744,7 +806,7 @@ def create_journey_instances(journey_templates, patients): hospital=template.hospital, status=random.choice(['active', 'completed']), ) - + # Create stage instances for stage_template in template.stages.all(): PatientJourneyStageInstance.objects.create( @@ -752,28 +814,28 @@ def create_journey_instances(journey_templates, patients): stage_template=stage_template, status=random.choice(['pending', 'completed']), ) - + instances.append(instance) print(f" Created {len(instances)} journey instances") return instances -def create_survey_instances(survey_templates, patients, physicians): +def create_survey_instances(survey_templates, patients, staff): """Create survey instances""" print("Creating survey instances...") from apps.surveys.models import SurveyTemplate - + templates = SurveyTemplate.objects.filter(is_active=True) if not templates.exists(): print(" No survey templates found, skipping...") return [] - + instances = [] for i in range(30): template = random.choice(templates) patient = random.choice(patients) - physician = random.choice(physicians) if random.random() > 0.3 else None - + staff_member = random.choice(staff) if random.random() > 0.3 else None + instance = SurveyInstance.objects.create( survey_template=template, patient=patient, @@ -782,9 +844,9 @@ def create_survey_instances(survey_templates, patients, physicians): recipient_email=patient.email, status=random.choice(['sent', 'completed']), sent_at=timezone.now() - timedelta(days=random.randint(1, 30)), - metadata={'physician_id': str(physician.id)} if physician else {}, + metadata={'staff_id': str(staff_member.id)} if staff_member else {}, ) - + # If completed, add responses if instance.status == 'completed': instance.completed_at = timezone.now() - timedelta(days=random.randint(0, 29)) @@ -803,7 +865,7 @@ def create_survey_instances(survey_templates, patients, physicians): ) instance.calculate_score() instance.save() - + instances.append(instance) print(f" Created {len(instances)} survey instances") return instances @@ -813,12 +875,12 @@ def create_call_center_interactions(patients, hospitals, users): """Create call center interactions""" print("Creating call center interactions...") from apps.callcenter.models import CallCenterInteraction - + interactions = [] for i in range(25): patient = random.choice(patients) hospital = patient.primary_hospital or random.choice(hospitals) - + interaction = CallCenterInteraction.objects.create( patient=patient, hospital=hospital, @@ -840,11 +902,11 @@ def create_social_mentions(hospitals): """Create social media mentions""" print("Creating social media mentions...") from apps.social.models import SocialMention - + mentions = [] for i in range(15): hospital = random.choice(hospitals) - + mention = SocialMention.objects.create( platform=random.choice(['twitter', 'facebook', 'instagram']), post_url=f"https://twitter.com/user/status/{random.randint(1000000, 9999999)}", @@ -865,26 +927,26 @@ def create_social_mentions(hospitals): return mentions -def create_physician_monthly_ratings(physicians): - """Create physician monthly ratings for the last 6 months""" - print("Creating physician monthly ratings...") +def create_staff_monthly_ratings(staff): + """Create staff monthly ratings for last 6 months""" + print("Creating staff monthly ratings...") from apps.physicians.models import PhysicianMonthlyRating from decimal import Decimal - + ratings = [] now = datetime.now() - + # Generate ratings for last 6 months - for physician in physicians: + for staff_member in staff: for months_ago in range(6): target_date = now - timedelta(days=30 * months_ago) year = target_date.year month = target_date.month - + # Generate realistic ratings (mostly good, some variation) base_rating = random.uniform(3.5, 4.8) total_surveys = random.randint(5, 25) - + # Calculate sentiment counts based on rating if base_rating >= 4.0: positive_count = int(total_surveys * random.uniform(0.7, 0.9)) @@ -895,11 +957,11 @@ def create_physician_monthly_ratings(physicians): else: positive_count = int(total_surveys * random.uniform(0.2, 0.4)) negative_count = int(total_surveys * random.uniform(0.3, 0.5)) - + neutral_count = total_surveys - positive_count - negative_count - + rating, created = PhysicianMonthlyRating.objects.get_or_create( - physician=physician, + physician=staff_member, year=year, month=month, defaults={ @@ -916,17 +978,17 @@ def create_physician_monthly_ratings(physicians): } ) ratings.append(rating) - + print(f" Created {len(ratings)} physician monthly ratings") - + # Update rankings for each month print(" Updating physician rankings...") from apps.physicians.models import PhysicianMonthlyRating from apps.organizations.models import Hospital, Department - + # Get unique year-month combinations periods = PhysicianMonthlyRating.objects.values_list('year', 'month').distinct() - + for year, month in periods: # Update hospital rankings hospitals = Hospital.objects.filter(status='active') @@ -936,11 +998,11 @@ def create_physician_monthly_ratings(physicians): year=year, month=month ).order_by('-average_rating') - + for rank, rating in enumerate(hospital_ratings, start=1): rating.hospital_rank = rank rating.save(update_fields=['hospital_rank']) - + # Update department rankings departments = Department.objects.filter(status='active') for department in departments: @@ -949,11 +1011,11 @@ def create_physician_monthly_ratings(physicians): year=year, month=month ).order_by('-average_rating') - + for rank, rating in enumerate(dept_ratings, start=1): rating.department_rank = rank rating.save(update_fields=['department_rank']) - + print(" ✓ Rankings updated successfully") return ratings @@ -961,11 +1023,11 @@ def create_physician_monthly_ratings(physicians): def create_appreciations(users, physicians, hospitals, departments, categories): """Create appreciations with 2 years of historical data""" print("Creating appreciations (2 years of data)...") - + # Get ContentType for User and Physician user_ct = ContentType.objects.get_for_model(User) physician_ct = ContentType.objects.get_for_model(Physician) - + # Message templates for generating realistic appreciations message_templates_en = [ "Thank you for {action}. Your {quality} made a real difference in our patient's care.", @@ -979,7 +1041,7 @@ def create_appreciations(users, physicians, hospitals, departments, categories): "Deeply grateful for your {action}. The {quality} you show is remarkable.", "Your {action} has made a significant impact. Your {quality} is truly appreciated.", ] - + message_templates_ar = [ "شكراً لك على {action}. {quality} الخاص بك أحدث فرقاً حقيقياً في رعاية مرضانا.", "أود أن أعرب عن تقديري الخالص لـ {action}. {quality} استثنائي حقاً.", @@ -992,7 +1054,7 @@ def create_appreciations(users, physicians, hospitals, departments, categories): "عميق الامتنان لـ {action}. {quality} الذي تظهره مذهل.", "لقد حدث {action} تأثيراً كبيراً. {quality} حقاً مقدر.", ] - + actions = [ "providing excellent patient care", "outstanding teamwork", "quick response in emergency", "thorough diagnosis", "compassionate patient interaction", "efficient workflow management", @@ -1000,16 +1062,16 @@ def create_appreciations(users, physicians, hospitals, departments, categories): "going the extra mile", "maintaining patient safety", "clear communication", "timely treatment", "excellent bedside manner", "professional conduct", ] - + qualities = [ "professionalism", "dedication", "expertise", "compassion", "attention to detail", "efficiency", "leadership", "teamwork", "reliability", "positive attitude", "patience", "kindness", "commitment", "excellence", "integrity", ] - + appreciations = [] now = timezone.now() - + # Weighted category distribution category_weights = { 'excellent_care': 30, @@ -1021,27 +1083,27 @@ def create_appreciations(users, physicians, hospitals, departments, categories): 'reliability': 3, 'mentorship': 2, } - + # Create category code mapping category_map = {cat.code: cat for cat in categories} - + # Generate appreciations over 2 years (730 days) # Average 1-2 appreciations per day = ~800-1200 total for day_offset in range(730): # Recency bias: more appreciations in recent months recency_factor = max(0.5, 1.0 - (day_offset / 1460)) # 0.5 to 1.0 - + # Number of appreciations per day (weighted by recency) base_count = random.choices([0, 1, 2], weights=[30, 50, 20])[0] num_appreciations = max(0, int(base_count * recency_factor)) - + for _ in range(num_appreciations): # Select sender (users only - users send appreciations) sender = random.choice(users) - + # Select recipient (70% physician, 30% user) is_physician_recipient = random.random() < 0.7 - + if is_physician_recipient: recipient = random.choice(physicians) recipient_ct = physician_ct @@ -1052,20 +1114,20 @@ def create_appreciations(users, physicians, hospitals, departments, categories): continue recipient = random.choice(potential_recipients) recipient_ct = user_ct - + # Ensure sender ≠ recipient if sender.id == recipient.id: continue - + # Determine hospital context if is_physician_recipient: hospital = recipient.hospital else: hospital = recipient.hospital if recipient.hospital else sender.hospital - + if not hospital: continue - + # Determine department context department = None if is_physician_recipient and recipient.department: @@ -1075,29 +1137,29 @@ def create_appreciations(users, physicians, hospitals, departments, categories): hospital_depts = [d for d in departments if d.hospital == hospital] if hospital_depts: department = random.choice(hospital_depts) - + # Select category (weighted distribution) category_codes = list(category_map.keys()) weights = [category_weights[code] for code in category_codes] category_code = random.choices(category_codes, weights=weights, k=1)[0] category = category_map.get(category_code) - + if not category: continue - + # Generate message action = random.choice(actions) quality = random.choice(qualities) message_en = random.choice(message_templates_en).format(action=action, quality=quality) message_ar = random.choice(message_templates_ar).format(action=action, quality=quality) - + # Select visibility (40% private, 25% dept, 25% hospital, 10% public) visibility = random.choices( list(AppreciationVisibility.values), weights=[40, 25, 25, 10], k=1 )[0] - + # Determine status based on age if day_offset < 1: # Last 24 hours status = random.choices( @@ -1117,17 +1179,17 @@ def create_appreciations(users, physicians, hospitals, departments, categories): weights=[1, 10, 89], k=1 )[0] - + # Calculate timestamps created_date = now - timedelta( days=day_offset, hours=random.randint(0, 23), minutes=random.randint(0, 59) ) - + sent_at = None acknowledged_at = None - + if status != AppreciationStatus.DRAFT: # Sent time: 0-24 hours after creation (for older appreciations) if day_offset < 1: @@ -1135,15 +1197,15 @@ def create_appreciations(users, physicians, hospitals, departments, categories): else: sent_delay = random.randint(1, 24) sent_at = created_date + timedelta(hours=sent_delay) - + if status == AppreciationStatus.ACKNOWLEDGED: # Acknowledged time: 1-72 hours after sent acknowledge_delay = random.randint(1, 72) acknowledged_at = sent_at + timedelta(hours=acknowledge_delay) - + # Anonymous option (15% anonymous) is_anonymous = random.random() < 0.15 - + # Create appreciation appreciation = Appreciation( sender=sender if not is_anonymous else None, @@ -1160,13 +1222,13 @@ def create_appreciations(users, physicians, hospitals, departments, categories): sent_at=sent_at, acknowledged_at=acknowledged_at, ) - + # Override created_at for historical data appreciation.created_at = created_date appreciation.save() - + appreciations.append(appreciation) - + print(f" Created {len(appreciations)} appreciations over 2 years") return appreciations @@ -1175,11 +1237,11 @@ def award_badges(badges, users, physicians, categories): """Award badges to users and physicians based on appreciations""" print("Awarding badges...") user_badges = [] - + # Get ContentType for User and Physician user_ct = ContentType.objects.get_for_model(User) physician_ct = ContentType.objects.get_for_model(Physician) - + # Badge criteria mapping (using codes from seed command) badge_criteria = { 'first_appreciation': {'min_count': 1, 'categories': None}, @@ -1192,26 +1254,26 @@ def award_badges(badges, users, physicians, categories): 'streak_4_weeks': {'min_count': 15, 'categories': None}, 'diverse_appreciation': {'min_count': 20, 'categories': None}, } - + badge_map = {badge.code: badge for badge in badges} - + # Award badges to users (30% get badges) for user in users: if random.random() > 0.7: # 30% of users get badges continue - + # Count appreciations received by this user received_count = Appreciation.objects.filter( recipient_content_type=user_ct, recipient_object_id=user.id, status=AppreciationStatus.ACKNOWLEDGED ).count() - + # Award appropriate badges for badge_code, criteria in badge_criteria.items(): if badge_code not in badge_map: continue - + if received_count >= criteria['min_count']: # Check if already has this badge existing = UserBadge.objects.filter( @@ -1219,7 +1281,7 @@ def award_badges(badges, users, physicians, categories): recipient_object_id=user.id, badge=badge_map[badge_code] ).first() - + if not existing: # Random chance to award (not guaranteed even if criteria met) if random.random() < 0.6: @@ -1229,24 +1291,24 @@ def award_badges(badges, users, physicians, categories): badge=badge_map[badge_code], appreciation_count=received_count, )) - + # Award badges to physicians (60% get badges) for physician in physicians: if random.random() > 0.6: # 60% of physicians get badges continue - + # Count appreciations received by this physician received_count = Appreciation.objects.filter( recipient_content_type=physician_ct, recipient_object_id=physician.id, status=AppreciationStatus.ACKNOWLEDGED ).count() - + # Award appropriate badges for badge_code, criteria in badge_criteria.items(): if badge_code not in badge_map: continue - + if received_count >= criteria['min_count']: # Check if already has this badge existing = UserBadge.objects.filter( @@ -1254,7 +1316,7 @@ def award_badges(badges, users, physicians, categories): recipient_object_id=physician.id, badge=badge_map[badge_code] ).first() - + if not existing: # Higher chance for physicians if random.random() < 0.7: @@ -1264,10 +1326,10 @@ def award_badges(badges, users, physicians, categories): badge=badge_map[badge_code], appreciation_count=received_count, )) - + # Bulk create user badges UserBadge.objects.bulk_create(user_badges) - + print(f" Awarded {len(user_badges)} badges to users and physicians") return user_badges @@ -1277,25 +1339,25 @@ def generate_appreciation_stats(users, physicians, hospitals): print("Generating appreciation statistics...") stats = [] now = timezone.now() - + # Get ContentType for User and Physician user_ct = ContentType.objects.get_for_model(User) physician_ct = ContentType.objects.get_for_model(Physician) - + # Get current year and month year = now.year month = now.month - + # Generate stats for users (70% have stats) for user in users: if random.random() > 0.7: continue - + # Generate realistic stats received_count = random.randint(0, 30) sent_count = random.randint(0, 50) acknowledged_count = int(received_count * random.uniform(0.6, 1.0)) - + stats.append(AppreciationStats( recipient_content_type=user_ct, recipient_object_id=user.id, @@ -1307,7 +1369,7 @@ def generate_appreciation_stats(users, physicians, hospitals): acknowledged_count=acknowledged_count, hospital_rank=random.randint(1, 20) if received_count > 0 else None, )) - + # Generate stats for physicians (90% have stats) for physician in physicians: if random.random() > 0.1: # 90% have stats @@ -1315,7 +1377,7 @@ def generate_appreciation_stats(users, physicians, hospitals): received_count = random.randint(5, 50) sent_count = random.randint(0, 20) # Physicians send less acknowledged_count = int(received_count * random.uniform(0.7, 1.0)) - + stats.append(AppreciationStats( recipient_content_type=physician_ct, recipient_object_id=physician.id, @@ -1328,10 +1390,10 @@ def generate_appreciation_stats(users, physicians, hospitals): acknowledged_count=acknowledged_count, hospital_rank=random.randint(1, 10) if received_count > 5 else None, )) - + # Bulk create stats AppreciationStats.objects.bulk_create(stats) - + print(f" Generated {len(stats)} appreciation statistics") return stats @@ -1339,13 +1401,13 @@ def generate_appreciation_stats(users, physicians, hospitals): def create_observations(hospitals, departments, users): """Create observations with 2 years of historical data""" print("Creating observations (2 years of data)...") - + # Get observation categories (should be seeded) obs_categories = list(ObservationCategory.objects.filter(is_active=True)) if not obs_categories: print(" WARNING: No observation categories found. Run: python manage.py seed_observation_categories") return [] - + # Observation descriptions observation_descriptions = [ "Noticed a wet floor near the elevator without any warning signs. This could be a slip hazard for patients and visitors.", @@ -1364,7 +1426,7 @@ def create_observations(hospitals, departments, users): "Emergency shower station in the lab has not been tested recently according to the log.", "Noticed outdated patient safety posters in the pediatric ward.", ] - + observation_titles = [ "Wet floor hazard", "Expired sanitizer", @@ -1382,32 +1444,32 @@ def create_observations(hospitals, departments, users): "Safety equipment", "Signage update needed", ] - + locations = [ "Main Entrance", "Emergency Department", "ICU", "Pediatric Ward", "Surgery Floor", "Radiology Department", "Laboratory", "Pharmacy", "Cafeteria", "Parking Garage", "Outpatient Clinic", "Rehabilitation Center", "Administration Building", "Staff Lounge", "Patient Rooms Floor 2", "Patient Rooms Floor 3", "Waiting Area", "Reception", ] - + observations = [] now = timezone.now() - + # Generate observations over 2 years (730 days) # Average 1-3 observations per day for day_offset in range(730): num_observations = random.choices([0, 1, 2, 3, 4], weights=[20, 35, 30, 10, 5])[0] - + for _ in range(num_observations): hospital = random.choice(hospitals) hospital_depts = [d for d in departments if d.hospital == hospital] - + created_date = now - timedelta( days=day_offset, hours=random.randint(0, 23), minutes=random.randint(0, 59) ) - + # Status distribution based on age if day_offset < 7: # Last week status = random.choices( @@ -1428,21 +1490,21 @@ def create_observations(hospitals, departments, users): ObservationStatus.REJECTED, ObservationStatus.DUPLICATE], weights=[2, 3, 5, 5, 40, 40, 3, 2] )[0] - + # Severity distribution severity = random.choices( - [ObservationSeverity.LOW, ObservationSeverity.MEDIUM, + [ObservationSeverity.LOW, ObservationSeverity.MEDIUM, ObservationSeverity.HIGH, ObservationSeverity.CRITICAL], weights=[30, 45, 20, 5] )[0] - + # Anonymous vs identified (60% anonymous) is_anonymous = random.random() < 0.6 - + # Generate tracking code import secrets tracking_code = f"OBS-{secrets.token_hex(3).upper()}" - + observation = Observation( tracking_code=tracking_code, category=random.choice(obs_categories), @@ -1459,23 +1521,23 @@ def create_observations(hospitals, departments, users): assigned_department=random.choice(hospital_depts) if hospital_depts and status not in [ObservationStatus.NEW] else None, assigned_to=random.choice(users) if status in [ObservationStatus.ASSIGNED, ObservationStatus.IN_PROGRESS, ObservationStatus.RESOLVED, ObservationStatus.CLOSED] and random.random() > 0.3 else None, ) - + # Set timestamps based on status if status not in [ObservationStatus.NEW]: observation.triaged_at = created_date + timedelta(hours=random.randint(1, 24)) observation.triaged_by = random.choice(users) - + if status in [ObservationStatus.RESOLVED, ObservationStatus.CLOSED]: observation.resolved_at = created_date + timedelta(hours=random.randint(24, 168)) - + if status == ObservationStatus.CLOSED: observation.closed_at = (observation.resolved_at or created_date) + timedelta(hours=random.randint(1, 48)) - + observation.save() - + # Override created_at Observation.objects.filter(pk=observation.pk).update(created_at=created_date) - + # Add status log entries if status != ObservationStatus.NEW: ObservationStatusLog.objects.create( @@ -1485,7 +1547,7 @@ def create_observations(hospitals, departments, users): changed_by=random.choice(users), comment="Observation triaged and categorized." ) - + if status in [ObservationStatus.ASSIGNED, ObservationStatus.IN_PROGRESS, ObservationStatus.RESOLVED, ObservationStatus.CLOSED]: ObservationStatusLog.objects.create( observation=observation, @@ -1494,7 +1556,7 @@ def create_observations(hospitals, departments, users): changed_by=random.choice(users), comment="Assigned to responsible department." ) - + if status in [ObservationStatus.RESOLVED, ObservationStatus.CLOSED]: ObservationStatusLog.objects.create( observation=observation, @@ -1503,7 +1565,7 @@ def create_observations(hospitals, departments, users): changed_by=random.choice(users), comment="Issue has been addressed and resolved." ) - + # Add internal notes for some observations if random.random() > 0.6: ObservationNote.objects.create( @@ -1512,9 +1574,9 @@ def create_observations(hospitals, departments, users): created_by=random.choice(users), is_internal=True ) - + observations.append(observation) - + print(f" Created {len(observations)} observations over 2 years") return observations @@ -1524,20 +1586,23 @@ def main(): print("\n" + "="*60) print("PX360 - Saudi-Influenced Data Generator") print("="*60 + "\n") - + # Clear existing data first clear_existing_data() - + # Create base data hospitals = create_hospitals() departments = create_departments(hospitals) - physicians = create_physicians(hospitals, departments) + staff = create_staff(hospitals, departments) patients = create_patients(hospitals) create_users(hospitals) - + # Get all users for assignments users = list(User.objects.all()) - + + # Create complaint categories first + categories = create_complaint_categories(hospitals) + # Create operational data complaints = create_complaints(patients, hospitals, physicians, users) inquiries = create_inquiries(patients, hospitals, users) @@ -1551,38 +1616,14 @@ def main(): call_interactions = create_call_center_interactions(patients, hospitals, users) social_mentions = create_social_mentions(hospitals) physician_ratings = create_physician_monthly_ratings(physicians) - - # Get appreciation categories and badges (should be seeded) - categories = list(AppreciationCategory.objects.all()) - badges = list(AppreciationBadge.objects.all()) - - if not categories or not badges: - print("\n" + "!"*60) - print("WARNING: Appreciation categories or badges not found!") - print("Please run: python manage.py seed_appreciation_data") - print("!"*60 + "\n") - - # Create appreciation data - if categories and badges: - appreciations = create_appreciations(users, physicians, hospitals, departments, categories) - user_badges = award_badges(badges, users, physicians, categories) - # Stats are auto-generated by signals, so we don't need to create them manually - appreciation_stats = list(AppreciationStats.objects.all()) - else: - appreciations = [] - user_badges = [] - appreciation_stats = [] - - # Create observations data - observations = create_observations(hospitals, departments, users) - + print("\n" + "="*60) print("Data Generation Complete!") print("="*60) print(f"\nCreated:") print(f" - {len(hospitals)} Hospitals") print(f" - {len(departments)} Departments") - print(f" - {len(physicians)} Physicians") + print(f" - {len(staff)} Staff") print(f" - {len(patients)} Patients") print(f" - {len(users)} Users") print(f" - {len(complaints)} Complaints (2 years)") @@ -1594,7 +1635,7 @@ def main(): print(f" - {len(call_interactions)} Call Center Interactions") print(f" - {len(social_mentions)} Social Media Mentions") print(f" - {len(projects)} QI Projects") - print(f" - {len(physician_ratings)} Physician Monthly Ratings") + print(f" - {len(staff_ratings)} Staff Monthly Ratings") print(f" - {len(appreciations)} Appreciations (2 years)") print(f" - {len(user_badges)} Badges Awarded") print(f" - {len(appreciation_stats)} Appreciation Statistics") diff --git a/pyproject.toml b/pyproject.toml index 72f93d2..7bc20e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/templates/complaints/complaint_detail.html b/templates/complaints/complaint_detail.html index dbbec63..2f3af0a 100644 --- a/templates/complaints/complaint_detail.html +++ b/templates/complaints/complaint_detail.html @@ -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 @@ - + -{#
#} -{# {{ user.first_name.0|default:user.username.0|upper }}#} -{#
#} +
+ {{ user.first_name.0|default:user.username.0|upper }} +