update complain and add ai and sentiment analysis

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

View File

@ -20,6 +20,12 @@ EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
DEFAULT_FROM_EMAIL=noreply@px360.sa
# AI Configuration (LiteLLM with OpenRouter)
OPENROUTER_API_KEY=
AI_MODEL=openai/gpt-4o-mini
AI_TEMPERATURE=0.3
AI_MAX_TOKENS=500
# Notification Channels
SMS_ENABLED=False
SMS_PROVIDER=console

120
COMPLAINT_CATEGORIES_FIX.md Normal file
View File

@ -0,0 +1,120 @@
# Complaint Categories Fix - Multi-Hospital Support
## Problem
The ComplaintCategory model had a ForeignKey relationship to Hospital, which prevented categories from being shared across multiple hospitals. Each category could only belong to one hospital.
## Solution
Changed the ComplaintCategory model from a ForeignKey to a ManyToMany relationship with Hospital. This allows:
- Categories to be assigned to multiple hospitals
- Categories with no hospitals (system-wide) to be available to all hospitals
- Each hospital to have its own custom categories while also accessing system-wide categories
## Changes Made
### 1. Model Changes (apps/complaints/models.py)
- Changed `hospital = models.ForeignKey(...)` to `hospitals = models.ManyToManyField(...)`
- Removed `Meta.indexes` that referenced the old hospital field
- Removed hospital-related `Meta.constraints`
- Updated docstrings to reflect the new relationship
### 2. Migration Created (apps/complaints/migrations/0003_alter_complaintcategory_options_and_more.py)
- Removes the old `hospital` field and its index
- Adds the new `hospitals` ManyToMany field
- Applies the changes successfully
### 3. Admin Interface Updated (apps/complaints/admin.py)
- Added filter_horizontal = ['hospitals'] for better UI
- Updated `hospitals_display` method to handle ManyToMany
- Shows hospital count or "System-wide" for categories
### 4. Management Command Updated (apps/complaints/management/commands/load_complaint_categories.py)
- Removed hospital reference from category/subcategory creation
- Categories are now created without hospital assignments (system-wide)
- Works correctly with the ManyToMany field
### 5. API Endpoint Updated (apps/complaints/ui_views.py)
```python
# Old code:
categories_queryset = ComplaintCategory.objects.filter(
Q(hospital_id=hospital_id) | Q(hospital__isnull=True),
is_active=True
).order_by('-hospital', 'order', 'name_en')
# New code:
categories_queryset = ComplaintCategory.objects.filter(
Q(hospitals__id=hospital_id) | Q(hospitals__isnull=True),
is_active=True
).distinct().order_by('order', 'name_en')
```
Key changes:
- Changed `hospital_id=hospital_id` to `hospitals__id=hospital_id`
- Changed `hospital__isnull=True` to `hospitals__isnull=True`
- Added `.distinct()` to remove duplicates
- Removed `-hospital` from ordering (no longer applicable)
## Testing Results
### Database Verification
```
Total parent categories: 5
Categories with hospitals: 0
System-wide categories: 5
```
### API Endpoint Test
```
Response status: 200
Categories count: 21
Sample categories:
- Cleanliness (Parent ID: 8952a7e3..., ID: 43ec2d94...) ← Subcategory
- Diagnosis concerns (Parent ID: 9e99195c..., ID: 20ce76ab...) ← Subcategory
- Privacy & Confidentiality (Parent ID: 755b053e..., ID: 564583fd...) ← Subcategory
- Quality of Care & Treatment (Parent ID: None, ID: 9e99195c...) ← Parent
- Staff attitude (Parent ID: b6302801..., ID: ffa88ba9...) ← Subcategory
```
## How It Works
### System-Wide Categories (Default)
Categories created without any hospital assignments are available to ALL hospitals:
```python
category = ComplaintCategory.objects.create(
code='quality_care',
name_en='Quality of Care & Treatment',
# No hospitals specified → available to all
)
```
### Hospital-Specific Categories
Categories can be assigned to specific hospitals:
```python
from apps.organizations.models import Hospital
hospitals = Hospital.objects.filter(status='active')
category = ComplaintCategory.objects.create(
code='special_category',
name_en='Special Category'
)
category.hospitals.add(*hospitals) # Assign to multiple hospitals
```
### Querying Categories
The API endpoint returns:
1. Hospital-specific categories (assigned to the hospital)
2. System-wide categories (no hospital assignment)
3. Both parent categories and their subcategories
## Benefits
1. **Flexibility**: Hospitals can share common categories while maintaining custom ones
2. **Efficiency**: No need to duplicate categories for each hospital
3. **Scalability**: Easy to add new categories that apply to all hospitals
4. **Maintainability**: System-wide changes can be made in one place
## Future Enhancements
1. **Category Prioritization**: Add a field to prioritize hospital-specific over system-wide categories
2. **Category Copying**: Create a management command to copy system-wide categories to hospital-specific
3. **Category Versioning**: Track changes to categories over time
4. **Category Analytics**: Report on which categories are most used per hospital

249
PUBLIC_COMPLAINT_FORM.md Normal file
View File

@ -0,0 +1,249 @@
# Public Complaint Form Implementation
## Overview
A public-facing complaint submission form that allows patients and visitors to submit complaints without requiring authentication. The form is fully bilingual (English/Arabic) and provides a seamless user experience.
## Features Implemented
### 1. Form Components
- **PublicComplaintForm** (apps/complaints/forms.py)
- Django form with validation for all complaint fields
- Supports file attachments (up to 5 files, 10MB each)
- Validates national ID format (10 digits)
- Dynamic field requirements based on patient lookup
### 2. Templates
- **public_complaint_form.html** - Main submission form
- Patient Information Section
- National ID lookup with auto-fill
- Auto-generated MRN display
- Contact information (name, phone, email)
- Preferred language selection
- Complaint Details Section
- Hospital and department dropdowns (cascading)
- Complaint category and subcategory
- Title and description fields
- Severity and priority selectors
- Encounter ID (optional)
- Attachments Section
- Multi-file upload with preview
- File size and type validation
- AJAX-powered interactions
- Patient lookup by National ID
- Department dropdown based on hospital selection
- Form submission with loading states
- Success modal with reference number
- **public_complaint_success.html** - Success confirmation page
- Displays complaint reference number
- Next steps information
- Contact information for urgent cases
- Navigation options
### 3. View Handlers (apps/complaints/ui_views.py)
#### public_complaint_submit
- Handles GET (display form) and POST (submit complaint)
- Creates new patient records when needed
- Generates unique reference numbers
- Handles file attachments
- Supports both traditional and AJAX submissions
- Validates all form inputs
- Creates initial complaint update
#### public_complaint_success
- Displays success page with reference number
- No authentication required
#### api_lookup_patient
- AJAX endpoint for patient lookup by National ID
- Returns patient MRN, name, phone, email
- No authentication required
- Handles patient not found cases
#### api_load_departments
- AJAX endpoint for cascading department dropdown
- Returns active departments for selected hospital
- No authentication required
### 4. URL Routes (apps/complaints/urls.py)
- `/complaints/public/submit/` - Public form
- `/complaints/public/success/<reference>/` - Success page
- `/complaints/public/api/lookup-patient/` - Patient lookup API
- `/complaints/public/api/load-departments/` - Department loading API
### 5. Key Features
#### National ID Lookup
- Real-time patient lookup with debounce (500ms)
- Auto-fills MRN, name, phone, email
- Shows loading spinner during lookup
- Handles not found case with fallback to manual entry
#### Cascading Dropdowns
- Hospital selection loads corresponding departments
- Departments disabled until hospital selected
- Shows error message on failure
#### File Upload
- Multiple file support
- Preview shows file names and sizes
- Client-side validation
- Server-side validation (type, size, count)
#### Form Validation
- Required field validation
- National ID format validation
- File type and size validation
- AJAX error handling with detailed messages
- SweetAlert2 for user-friendly error messages
#### Bilingual Support
- All text uses Django i18n templates
- Arabic and English translations
- RTL language support
- Language-aware date/time formatting
#### User Experience
- Responsive design for mobile and desktop
- Loading states for all async operations
- Success modal with reference number
- Clear visual feedback for all interactions
- Informative help text throughout
### 6. Security Considerations
- CSRF protection enabled
- File type validation (images, PDF, DOC, DOCX)
- File size limits (10MB per file, 5 files max)
- Input sanitization via Django forms
- Rate limiting should be added in production
### 7. Patient Creation Logic
When a patient is not found:
- Auto-generates MRN using Patient.generate_mrn()
- Splits full name into first/last name
- Sets selected hospital as primary
- Sets status to 'active'
- Stores all provided contact information
### 8. Complaint Creation Logic
- Sets source='public' to identify public submissions
- Starts with status='open'
- Creates initial update note
- Links to patient (new or existing)
- Stores all form data
### 9. Integration Points
- Uses existing Patient model from apps.organizations
- Uses Hospital and Department models
- Creates Complaint and ComplaintAttachment records
- Creates initial ComplaintUpdate for audit trail
- Reference number format: CMP-{YYYYMMDD}-{unique_id}
## Usage
### Accessing the Form
The public form is accessible at:
```
/complaints/public/submit/
```
No authentication required.
### Example Submission Flow
1. User enters National ID (10 digits)
2. System looks up patient and auto-fills data
3. If not found, user manually enters contact info
4. User selects hospital (loads departments)
5. User optionally selects department
6. User selects complaint category
7. User enters title and description
8. User sets severity and priority
9. User optionally uploads attachments
10. User submits form
11. System creates patient (if needed) and complaint
12. User sees success page with reference number
## Testing Checklist
- [ ] Form loads without authentication
- [ ] National ID lookup works with valid patient
- [ ] National ID lookup shows not found message for invalid ID
- [ ] Hospital dropdown loads active hospitals
- [ ] Department dropdown cascades correctly
- [ ] File upload preview shows file info
- [ ] Form validates required fields
- [ ] Form validates file types and sizes
- [ ] Complaint is created successfully
- [ ] Patient is created when not found
- [ ] Reference number is generated
- [ ] Success page displays correctly
- [ ] AJAX submissions work
- [ ] Traditional submissions work
- [ ] Bilingual switching works
- [ ] RTL layout displays correctly
- [ ] Mobile responsive design works
## Future Enhancements
1. **Email Notifications**
- Send confirmation email to patient
- Send notification to hospital staff
2. **SMS Notifications**
- Send SMS with reference number
- Send status update notifications
3. **Complaint Tracking**
- Allow users to check status by reference number
- Public status page
4. **Rate Limiting**
- Prevent abuse from same IP
- CAPTCHA for suspicious activity
5. **Additional Validation**
- Phone number format validation
- Email format validation
- Custom validation rules per hospital
6. **Enhanced UX**
- Progress indicator
- Save draft functionality
- Multi-step form with review
## Dependencies
- Django (form handling)
- jQuery (AJAX)
- SweetAlert2 (alerts)
- Bootstrap 4 (styling)
- Django i18n (translations)
## Files Modified/Created
### Created
- apps/complaints/forms.py - PublicComplaintForm
- templates/complaints/public_complaint_form.html - Main form
- templates/complaints/public_complaint_success.html - Success page
### Modified
- apps/complaints/ui_views.py - Added public form views
- apps/complaints/urls.py - Added public form routes
## Deployment Notes
1. Ensure media files are properly configured for file uploads
2. Set up email backend for notifications
3. Configure CORS if needed for AJAX requests
4. Review and adjust file size limits as needed
5. Ensure translation files are updated for new strings
6. Consider adding analytics tracking
7. Set up monitoring for form submissions
## Support
For issues or questions about the public complaint form, contact:
- Development Team
- Patient Relations Department

View File

@ -116,3 +116,9 @@ USE_TZ = True
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
OPENROUTER_API_KEY = "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"
AI_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:07
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.contrib.auth.models
import django.contrib.auth.validators

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:07
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
from django.db import migrations, models

View File

@ -43,13 +43,13 @@ User = get_user_model()
class CustomTokenObtainPairView(TokenObtainPairView):
"""
Custom JWT token view that logs user login.
Custom JWT token view that logs user login and provides redirect info.
"""
def post(self, request, *args, **kwargs):
response = super().post(request, *args, **kwargs)
# Log successful login
# Log successful login and add redirect info
if response.status_code == 200:
username = request.data.get('username')
try:
@ -60,11 +60,36 @@ 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):
"""

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 11:25
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 11:19
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid

View File

@ -12,7 +12,7 @@ from django.views.decorators.http import require_http_methods
from apps.complaints.models import Complaint, ComplaintSource, Inquiry
from apps.core.services import AuditService
from apps.organizations.models import Department, Hospital, Patient, Physician
from apps.organizations.models import Department, Hospital, Patient, Staff
from .models import CallCenterInteraction
@ -133,7 +133,7 @@ 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')
@ -163,7 +163,7 @@ def create_complaint(request):
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,
@ -244,7 +244,7 @@ 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
@ -512,21 +512,27 @@ def get_departments_by_hospital(request):
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': []})
return JsonResponse({'staff': []})
physicians = Physician.objects.filter(
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

View File

@ -20,6 +20,6 @@ urlpatterns = [
# AJAX Helpers
path('ajax/departments/', ui_views.get_departments_by_hospital, name='ajax_departments'),
path('ajax/physicians/', ui_views.get_physicians_by_hospital, name='ajax_physicians'),
path('ajax/physicians/', ui_views.get_staff_by_hospital, name='ajax_physicians'),
path('ajax/patients/', ui_views.search_patients, name='ajax_patients'),
]

View File

@ -58,7 +58,7 @@ class ComplaintAdmin(admin.ModelAdmin):
'fields': ('patient', 'encounter_id')
}),
('Organization', {
'fields': ('hospital', 'department', 'physician')
'fields': ('hospital', 'department', 'staff')
}),
('Complaint Details', {
'fields': ('title', 'description', 'category', 'subcategory')
@ -93,7 +93,7 @@ class ComplaintAdmin(admin.ModelAdmin):
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'
)
@ -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')
@ -340,10 +340,22 @@ class ComplaintCategoryAdmin(admin.ModelAdmin):
)
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)

335
apps/complaints/forms.py Normal file
View File

@ -0,0 +1,335 @@
"""
Complaints forms
"""
from django import forms
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _
from apps.complaints.models import (
Complaint,
ComplaintCategory,
ComplaintSource,
ComplaintStatus,
)
from apps.core.models import PriorityChoices, SeverityChoices
from apps.organizations.models import Department, Hospital
class MultiFileInput(forms.FileInput):
"""
Custom FileInput widget that supports multiple file uploads.
Unlike the standard FileInput which only supports single files,
this widget allows users to upload multiple files at once.
"""
def __init__(self, attrs=None):
# Call parent's __init__ first to avoid Django's 'multiple' check
super().__init__(attrs)
# Add 'multiple' attribute after initialization
self.attrs['multiple'] = 'multiple'
def value_from_datadict(self, data, files, name):
"""
Get all uploaded files for the given field name.
Returns a list of uploaded files instead of a single file.
"""
if name in files:
return files.getlist(name)
return []
class PublicComplaintForm(forms.ModelForm):
"""
Simplified public complaint submission form.
Key changes for AI-powered classification:
- Fewer required fields (simplified for public users)
- Severity and priority removed (AI will determine these automatically)
- Only essential information collected
"""
# Contact Information
name = forms.CharField(
label=_("Name"),
max_length=200,
required=True,
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _('Your full name')
}
)
)
email = forms.EmailField(
label=_("Email Address"),
required=True,
widget=forms.EmailInput(
attrs={
'class': 'form-control',
'placeholder': _('your@email.com')
}
)
)
phone = forms.CharField(
label=_("Phone Number"),
max_length=20,
required=True,
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _('Your phone number')
}
)
)
# Hospital and Department
hospital = forms.ModelChoiceField(
label=_("Hospital"),
queryset=Hospital.objects.filter(status='active').order_by('name'),
empty_label=_("Select Hospital"),
required=True,
widget=forms.Select(
attrs={
'class': 'form-control',
'id': 'hospital_select',
'data-action': 'load-departments'
}
)
)
department = forms.ModelChoiceField(
label=_("Department (Optional)"),
queryset=Department.objects.none(),
empty_label=_("Select Department"),
required=False,
widget=forms.Select(
attrs={
'class': 'form-control',
'id': 'department_select'
}
)
)
# Complaint Details
category = forms.ModelChoiceField(
label=_("Complaint Category"),
queryset=ComplaintCategory.objects.filter(is_active=True).order_by('name_en'),
empty_label=_("Select Category"),
required=True,
widget=forms.Select(
attrs={
'class': 'form-control',
'id': 'category_select'
}
)
)
title = forms.CharField(
label=_("Complaint Title"),
max_length=200,
required=True,
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _('Brief title of your complaint')
}
)
)
description = forms.CharField(
label=_("Complaint Description"),
required=True,
widget=forms.Textarea(
attrs={
'class': 'form-control',
'rows': 6,
'placeholder': _('Please describe your complaint in detail. Our AI system will analyze and prioritize your complaint accordingly.')
}
)
)
# Hidden fields - these will be set by the view or AI
severity = forms.ChoiceField(
label=_("Severity"),
choices=SeverityChoices.choices,
initial=SeverityChoices.MEDIUM,
required=False,
widget=forms.HiddenInput()
)
priority = forms.ChoiceField(
label=_("Priority"),
choices=PriorityChoices.choices,
initial=PriorityChoices.MEDIUM,
required=False,
widget=forms.HiddenInput()
)
# File uploads
attachments = forms.FileField(
label=_("Attach Documents (Optional)"),
required=False,
widget=MultiFileInput(
attrs={
'class': 'form-control',
'accept': 'image/*,.pdf,.doc,.docx'
}
),
help_text=_('You can upload images, PDFs, or Word documents (max 10MB each)')
)
class Meta:
model = Complaint
fields = [
'name', 'email', 'phone', 'hospital', 'department',
'category', 'title', 'description', 'severity', 'priority'
]
# Note: 'attachments' is not in fields because Complaint model doesn't have this field.
# Attachments are handled separately via ComplaintAttachment model in the view.
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Check both initial data and POST data for hospital
hospital_id = None
if 'hospital' in self.initial:
hospital_id = self.initial['hospital']
elif 'hospital' in self.data:
hospital_id = self.data['hospital']
if hospital_id:
# Filter departments
self.fields['department'].queryset = Department.objects.filter(
hospital_id=hospital_id,
status='active'
).order_by('name')
# Filter categories (show hospital-specific first, then system-wide)
self.fields['category'].queryset = ComplaintCategory.objects.filter(
models.Q(hospital_id=hospital_id) | models.Q(hospital__isnull=True),
is_active=True
).order_by('hospital', 'order', 'name_en')
def clean_attachments(self):
"""Validate file attachments"""
files = self.files.getlist('attachments')
# Check file count
if len(files) > 5:
raise ValidationError(_('Maximum 5 files allowed'))
# Check each file
for file in files:
# Check file size (10MB limit)
if file.size > 10 * 1024 * 1024:
raise ValidationError(_('File size must be less than 10MB'))
# Check file type
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx']
import os
ext = os.path.splitext(file.name)[1].lower()
if ext not in allowed_extensions:
raise ValidationError(_('Allowed file types: JPG, PNG, GIF, PDF, DOC, DOCX'))
return files
def clean(self):
"""Custom cross-field validation"""
cleaned_data = super().clean()
# Basic validation - all required fields are already validated by field definitions
# This method is kept for future custom cross-field validation needs
return cleaned_data
class PublicInquiryForm(forms.Form):
"""Public inquiry submission form (simpler, for general questions)"""
# Contact Information
name = forms.CharField(
label=_("Name"),
max_length=200,
required=True,
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _('Your full name')
}
)
)
phone = forms.CharField(
label=_("Phone Number"),
max_length=20,
required=True,
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _('Your phone number')
}
)
)
email = forms.EmailField(
label=_("Email Address"),
required=False,
widget=forms.EmailInput(
attrs={
'class': 'form-control',
'placeholder': _('your@email.com')
}
)
)
# Inquiry Details
hospital = forms.ModelChoiceField(
label=_("Hospital"),
queryset=Hospital.objects.filter(status='active').order_by('name'),
empty_label=_("Select Hospital"),
required=True,
widget=forms.Select(attrs={'class': 'form-control'})
)
category = forms.ChoiceField(
label=_("Inquiry Type"),
choices=[
('general', 'General Inquiry'),
('appointment', 'Appointment Related'),
('billing', 'Billing & Insurance'),
('medical_records', 'Medical Records'),
('other', 'Other'),
],
required=True,
widget=forms.Select(attrs={'class': 'form-control'})
)
subject = forms.CharField(
label=_("Subject"),
max_length=200,
required=True,
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _('Brief subject')
}
)
)
message = forms.CharField(
label=_("Message"),
required=True,
widget=forms.Textarea(
attrs={
'class': 'form-control',
'rows': 5,
'placeholder': _('Describe your inquiry')
}
)
)

View File

@ -0,0 +1,241 @@
from django.core.management.base import BaseCommand
from apps.complaints.models import ComplaintCategory
class Command(BaseCommand):
help = 'Load default complaint categories and subcategories'
def handle(self, *args, **options):
"""Create default complaint categories with subcategories"""
# Define categories and subcategories
categories_data = [
{
'code': 'quality_care',
'name_en': 'Quality of Care & Treatment',
'name_ar': 'جودة الرعاية والعلاج',
'description_en': 'This is for concerns about the actual medical help received.',
'description_ar': 'هذا للمخاوف المتعلقة بالمساعدة الطبية الفعلية المتلقاة.',
'order': 1,
'subcategories': [
{
'code': 'diagnosis',
'name_en': 'Diagnosis concerns',
'name_ar': 'مخاوف التشخيص',
'description_en': 'Feeling that a condition was missed or incorrectly identified.',
'description_ar': 'الشعور بأن الحالة تم تفويتها أو تحديدها بشكل غير صحيح.',
'order': 1
},
{
'code': 'treatment_effectiveness',
'name_en': 'Treatment effectiveness',
'name_ar': 'فعالية العلاج',
'description_en': 'Feeling that the treatment didn\'t work or made things worse.',
'description_ar': 'الشعور بأن العلاج لم يعمل أو جعل الأمور أسوأ.',
'order': 2
},
{
'code': 'safety_errors',
'name_en': 'Safety & Errors',
'name_ar': 'السلامة والأخطاء',
'description_en': 'Concerns about medication mistakes, falls, or surgical complications.',
'description_ar': 'مخاوف بشأن أخطاء الأدوية، أو السقوط، أو مضاعفات الجراحة.',
'order': 3
},
{
'code': 'pain_management',
'name_en': 'Pain management',
'name_ar': 'إدارة الألم',
'description_en': 'Feeling that pain was not taken seriously or handled well.',
'description_ar': 'الشعور بأن الألم لم يؤخذ بجدية أو تم التعامل معه بشكل جيد.',
'order': 4
}
]
},
{
'code': 'communication',
'name_en': 'Communication & Information',
'name_ar': 'التواصل والمعلومات',
'description_en': 'This covers how staff spoke to the patient and the clarity of information provided.',
'description_ar': 'يغطي هذا كيف تحدث الموظفون مع المريض ووضوح المعلومات المقدمة.',
'order': 2,
'subcategories': [
{
'code': 'staff_attitude',
'name_en': 'Staff attitude',
'name_ar': 'سلوك الموظفين',
'description_en': 'Feeling that staff were rude, dismissive, or lacked empathy.',
'description_ar': 'الشعور بأن الموظفين كانوا غير مهذبين، أو متعالين، أو يفتقرون إلى التعاطف.',
'order': 1
},
{
'code': 'lack_information',
'name_en': 'Lack of information',
'name_ar': 'نقص المعلومات',
'description_en': 'Not being told enough about a procedure, risks, or "what happens next."',
'description_ar': 'عدم إخبارهم بما يكفي عن إجراء، أو المخاطر، أو "ماذا يحدث بعد ذلك".',
'order': 2
},
{
'code': 'language_listening',
'name_en': 'Language & Listening',
'name_ar': 'اللغة والاستماع',
'description_en': 'Difficulty being understood or feeling that doctors didn\'t listen to the patient\'s concerns.',
'description_ar': 'صعوبة فهمهم أو الشعور بأن الأطباء لم يستمعوا لاهتمامات المريض.',
'order': 3
}
]
},
{
'code': 'access_timing',
'name_en': 'Access & Timing',
'name_ar': 'الوصول والتوقيت',
'description_en': 'These are "logistical" complaints regarding the patient\'s schedule and ability to get care.',
'description_ar': 'هذه شكاوى "لوجستية" تتعلق بجدول المريض وقدرته على الحصول على الرعاية.',
'order': 3,
'subcategories': [
{
'code': 'waiting_times',
'name_en': 'Waiting times',
'name_ar': 'أوقات الانتظار',
'description_en': 'Long delays in the waiting room or waiting too long for a scheduled surgery.',
'description_ar': 'تأخير طويل في غرفة الانتظار أو الانتظار لفترة طويلة جداً لعملية جراحية مجدولة.',
'order': 1
},
{
'code': 'appointment_issues',
'name_en': 'Appointment issues',
'name_ar': 'مشاكل المواعيد',
'description_en': 'Difficulty booking an appointment or having one cancelled at the last minute.',
'description_ar': 'صعوبة حجز موعد أو إلغاء موعد في اللحظة الأخيرة.',
'order': 2
},
{
'code': 'referrals',
'name_en': 'Referrals',
'name_ar': 'الإحالات',
'description_en': 'Problems getting sent to a specialist or another hospital.',
'description_ar': 'مشاكل في الإحالة إلى أخصائي أو مستشفى آخر.',
'order': 3
}
]
},
{
'code': 'facility_environment',
'name_en': 'Facility & Environment',
'name_ar': 'المرفق والبيئة',
'description_en': 'This focuses on the physical space where care was provided.',
'description_ar': 'يركز هذا على المساحة المادية حيث تم تقديم الرعاية.',
'order': 4,
'subcategories': [
{
'code': 'cleanliness',
'name_en': 'Cleanliness',
'name_ar': 'النظافة',
'description_en': 'Issues with the hygiene of rooms, bathrooms, or equipment.',
'description_ar': 'مشاكل في نظافة الغرف، أو الحمامات، أو المعدات.',
'order': 1
},
{
'code': 'food_catering',
'name_en': 'Food & Catering',
'name_ar': 'الطعام والتموين',
'description_en': 'Poor quality or incorrect meals during a hospital stay.',
'description_ar': 'جودة رديئة أو وجبات غير صحيحة خلال إقامة المستشفى.',
'order': 2
},
{
'code': 'environment',
'name_en': 'Environment',
'name_ar': 'البيئة',
'description_en': 'Issues with noise levels, room temperature, or parking.',
'description_ar': 'مشاكل مع مستويات الضوضاء، أو درجة حرارة الغرفة، أو مواقف السيارات.',
'order': 3
}
]
},
{
'code': 'rights_privacy_billing',
'name_en': 'Rights, Privacy & Billing',
'name_ar': 'الحقوق والخصوصية والفواتير',
'description_en': 'These involve the administrative and ethical side of the healthcare experience.',
'description_ar': 'هذه تتضمن الجانب الإداري والأخلاقي لتجربة الرعاية الصحية.',
'order': 5,
'subcategories': [
{
'code': 'privacy_confidentiality',
'name_en': 'Privacy & Confidentiality',
'name_ar': 'الخصوصية والسرية',
'description_en': 'Feeling that personal medical information was shared inappropriately.',
'description_ar': 'الشعور بأن المعلومات الطبية الشخصية تم مشاركتها بشكل غير مناسب.',
'order': 1
},
{
'code': 'consent',
'name_en': 'Consent',
'name_ar': 'الموافقة',
'description_en': 'Feeling pressured into a decision or not being asked for permission before a treatment.',
'description_ar': 'الشعور بالضغط لاتخاذ قرار أو عدم طلب الإذن قبل العلاج.',
'order': 2
},
{
'code': 'billing_costs',
'name_en': 'Billing & Costs',
'name_ar': 'الفواتير والتكاليف',
'description_en': 'Confusion or disagreement over charges and insurance.',
'description_ar': 'الارتباك أو الخلاف حول الرسوم والتأمين.',
'order': 3
}
]
}
]
# Create categories
created_count = 0
updated_count = 0
for category_data in categories_data:
subcategories_data = category_data.pop('subcategories', None)
# Get or create category (system-wide - no hospitals assigned)
category, created = ComplaintCategory.objects.update_or_create(
code=category_data['code'],
defaults=category_data
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'Created category: {category.name_en}')
)
else:
updated_count += 1
self.stdout.write(
self.style.WARNING(f'Updated category: {category.name_en}')
)
# Create subcategories
if subcategories_data:
for subcat_data in subcategories_data:
subcat, sub_created = ComplaintCategory.objects.update_or_create(
code=subcat_data['code'],
parent=category,
defaults=subcat_data
)
if sub_created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f' Created subcategory: {subcat.name_en}')
)
else:
updated_count += 1
self.stdout.write(
self.style.WARNING(f' Updated subcategory: {subcat.name_en}')
)
self.stdout.write(
self.style.SUCCESS(
f'\nDone! Created {created_count} items, updated {updated_count} items.'
)
)

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:56
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid
@ -12,21 +12,147 @@ class Migration(migrations.Migration):
dependencies = [
('organizations', '0001_initial'),
('surveys', '0002_surveyquestion_surveyresponse_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ComplaintAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='complaints/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ComplaintCategory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(help_text='Unique code for this category', max_length=50)),
('name_en', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('description_en', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True)),
('order', models.IntegerField(default=0, help_text='Display order')),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name_plural': 'Complaint Categories',
'ordering': ['hospital', 'order', 'name_en'],
},
),
migrations.CreateModel(
name='ComplaintSLAConfig',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Severity level for this SLA', max_length=20)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Priority level for this SLA', max_length=20)),
('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')),
('reminder_hours_before', models.IntegerField(default=24, help_text='Send reminder X hours before deadline')),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['hospital', 'severity', 'priority'],
},
),
migrations.CreateModel(
name='ComplaintThreshold',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('threshold_type', models.CharField(choices=[('resolution_survey_score', 'Resolution Survey Score'), ('response_time', 'Response Time'), ('resolution_time', 'Resolution Time')], help_text='Type of threshold', max_length=50)),
('threshold_value', models.FloatField(help_text='Threshold value (e.g., 50 for 50% score)')),
('comparison_operator', models.CharField(choices=[('lt', 'Less Than'), ('lte', 'Less Than or Equal'), ('gt', 'Greater Than'), ('gte', 'Greater Than or Equal'), ('eq', 'Equal')], default='lt', help_text='How to compare against threshold', max_length=10)),
('action_type', models.CharField(choices=[('create_px_action', 'Create PX Action'), ('send_notification', 'Send Notification'), ('escalate', 'Escalate')], help_text='Action to take when threshold is breached', max_length=50)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['hospital', 'threshold_type'],
},
),
migrations.CreateModel(
name='ComplaintUpdate',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('update_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Note'), ('resolution', 'Resolution'), ('escalation', 'Escalation'), ('communication', 'Communication')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('metadata', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='EscalationRule',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('trigger_on_overdue', models.BooleanField(default=True, help_text='Trigger when complaint is overdue')),
('trigger_hours_overdue', models.IntegerField(default=0, help_text='Trigger X hours after overdue (0 = immediately)')),
('escalate_to_role', models.CharField(choices=[('department_manager', 'Department Manager'), ('hospital_admin', 'Hospital Admin'), ('px_admin', 'PX Admin'), ('specific_user', 'Specific User')], help_text='Role to escalate to', max_length=50)),
('severity_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this severity (blank = all)', max_length=20)),
('priority_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this priority (blank = all)', max_length=20)),
('order', models.IntegerField(default=0, help_text='Escalation order (lower = first)')),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['hospital', 'order'],
},
),
migrations.CreateModel(
name='Inquiry',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('contact_name', models.CharField(blank=True, max_length=200)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('contact_email', models.EmailField(blank=True, max_length=254)),
('subject', models.CharField(max_length=500)),
('message', models.TextField()),
('category', models.CharField(choices=[('appointment', 'Appointment'), ('billing', 'Billing'), ('medical_records', 'Medical Records'), ('general', 'General Information'), ('other', 'Other')], max_length=100)),
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='open', max_length=20)),
('response', models.TextField(blank=True)),
('responded_at', models.DateTimeField(blank=True, null=True)),
],
options={
'verbose_name_plural': 'Inquiries',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Complaint',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('contact_name', models.CharField(blank=True, max_length=200)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('contact_email', models.EmailField(blank=True, max_length=254)),
('reference_number', models.CharField(blank=True, db_index=True, help_text='Unique reference number for patient tracking', max_length=50, null=True, unique=True)),
('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)),
('title', models.CharField(max_length=500)),
('description', models.TextField()),
('category', models.CharField(choices=[('clinical_care', 'Clinical Care'), ('staff_behavior', 'Staff Behavior'), ('facility', 'Facility & Environment'), ('wait_time', 'Wait Time'), ('billing', 'Billing'), ('communication', 'Communication'), ('other', 'Other')], db_index=True, max_length=100)),
('subcategory', models.CharField(blank=True, max_length=100)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
@ -46,103 +172,10 @@ class Migration(migrations.Migration):
('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_complaints', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.hospital')),
('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.patient')),
('physician', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.physician')),
('resolution_survey', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_resolution', to='surveys.surveyinstance')),
('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_complaints', to=settings.AUTH_USER_MODEL)),
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.patient')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ComplaintAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='complaints/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
('complaint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaint')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_attachments', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ComplaintUpdate',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('update_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Note'), ('resolution', 'Resolution'), ('escalation', 'Escalation'), ('communication', 'Communication')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('metadata', models.JSONField(blank=True, default=dict)),
('complaint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='complaints.complaint')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_updates', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Inquiry',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('contact_name', models.CharField(blank=True, max_length=200)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('contact_email', models.EmailField(blank=True, max_length=254)),
('subject', models.CharField(max_length=500)),
('message', models.TextField()),
('category', models.CharField(choices=[('appointment', 'Appointment'), ('billing', 'Billing'), ('medical_records', 'Medical Records'), ('general', 'General Information'), ('other', 'Other')], max_length=100)),
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='open', max_length=20)),
('response', models.TextField(blank=True)),
('responded_at', models.DateTimeField(blank=True, null=True)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_inquiries', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.hospital')),
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.patient')),
('responded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responded_inquiries', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Inquiries',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['status', '-created_at'], name='complaints__status_f077e8_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='complaints__hospita_cf53df_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['is_overdue', 'status'], name='complaints__is_over_3d3554_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['due_at', 'status'], name='complaints__due_at_836821_idx'),
),
migrations.AddIndex(
model_name='complaintupdate',
index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_f3684e_idx'),
),
migrations.AddIndex(
model_name='inquiry',
index=models.Index(fields=['status', '-created_at'], name='complaints__status_3d0678_idx'),
),
migrations.AddIndex(
model_name='inquiry',
index=models.Index(fields=['hospital', 'status'], name='complaints__hospita_b1573b_idx'),
),
]

View File

@ -1,100 +0,0 @@
# Generated by Django 5.0.14 on 2025-12-25 13:56
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0001_initial'),
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ComplaintCategory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(help_text='Unique code for this category', max_length=50)),
('name_en', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('description_en', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True)),
('order', models.IntegerField(default=0, help_text='Display order')),
('is_active', models.BooleanField(default=True)),
('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide categories', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='complaint_categories', to='organizations.hospital')),
('parent', models.ForeignKey(blank=True, help_text='Parent category for hierarchical structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='complaints.complaintcategory')),
],
options={
'verbose_name_plural': 'Complaint Categories',
'ordering': ['hospital', 'order', 'name_en'],
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_a31674_idx'), models.Index(fields=['code'], name='complaints__code_8e9bbe_idx')],
},
),
migrations.CreateModel(
name='ComplaintSLAConfig',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Severity level for this SLA', max_length=20)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Priority level for this SLA', max_length=20)),
('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')),
('reminder_hours_before', models.IntegerField(default=24, help_text='Send reminder X hours before deadline')),
('is_active', models.BooleanField(default=True)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_sla_configs', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'severity', 'priority'],
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_bdf8a5_idx')],
'unique_together': {('hospital', 'severity', 'priority')},
},
),
migrations.CreateModel(
name='ComplaintThreshold',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('threshold_type', models.CharField(choices=[('resolution_survey_score', 'Resolution Survey Score'), ('response_time', 'Response Time'), ('resolution_time', 'Resolution Time')], help_text='Type of threshold', max_length=50)),
('threshold_value', models.FloatField(help_text='Threshold value (e.g., 50 for 50% score)')),
('comparison_operator', models.CharField(choices=[('lt', 'Less Than'), ('lte', 'Less Than or Equal'), ('gt', 'Greater Than'), ('gte', 'Greater Than or Equal'), ('eq', 'Equal')], default='lt', help_text='How to compare against threshold', max_length=10)),
('action_type', models.CharField(choices=[('create_px_action', 'Create PX Action'), ('send_notification', 'Send Notification'), ('escalate', 'Escalate')], help_text='Action to take when threshold is breached', max_length=50)),
('is_active', models.BooleanField(default=True)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_thresholds', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'threshold_type'],
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_b8efc9_idx'), models.Index(fields=['threshold_type', 'is_active'], name='complaints__thresho_719969_idx')],
},
),
migrations.CreateModel(
name='EscalationRule',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('trigger_on_overdue', models.BooleanField(default=True, help_text='Trigger when complaint is overdue')),
('trigger_hours_overdue', models.IntegerField(default=0, help_text='Trigger X hours after overdue (0 = immediately)')),
('escalate_to_role', models.CharField(choices=[('department_manager', 'Department Manager'), ('hospital_admin', 'Hospital Admin'), ('px_admin', 'PX Admin'), ('specific_user', 'Specific User')], help_text='Role to escalate to', max_length=50)),
('severity_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this severity (blank = all)', max_length=20)),
('priority_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this priority (blank = all)', max_length=20)),
('order', models.IntegerField(default=0, help_text='Escalation order (lower = first)')),
('is_active', models.BooleanField(default=True)),
('escalate_to_user', models.ForeignKey(blank=True, help_text="Specific user if escalate_to_role is 'specific_user'", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalation_target_rules', to=settings.AUTH_USER_MODEL)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='escalation_rules', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'order'],
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_3c8bac_idx')],
},
),
]

View File

@ -0,0 +1,171 @@
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('complaints', '0001_initial'),
('organizations', '0001_initial'),
('surveys', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='complaint',
name='resolution_survey',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_resolution', to='surveys.surveyinstance'),
),
migrations.AddField(
model_name='complaint',
name='resolved_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_complaints', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='complaint',
name='staff',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.staff'),
),
migrations.AddField(
model_name='complaintattachment',
name='complaint',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaint'),
),
migrations.AddField(
model_name='complaintattachment',
name='uploaded_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_attachments', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='complaintcategory',
name='hospital',
field=models.ForeignKey(blank=True, help_text='Leave blank for system-wide categories', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='complaint_categories', to='organizations.hospital'),
),
migrations.AddField(
model_name='complaintcategory',
name='parent',
field=models.ForeignKey(blank=True, help_text='Parent category for hierarchical structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='complaints.complaintcategory'),
),
migrations.AddField(
model_name='complaint',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='complaints.complaintcategory'),
),
migrations.AddField(
model_name='complaintslaconfig',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_sla_configs', to='organizations.hospital'),
),
migrations.AddField(
model_name='complaintthreshold',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_thresholds', to='organizations.hospital'),
),
migrations.AddField(
model_name='complaintupdate',
name='complaint',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='complaints.complaint'),
),
migrations.AddField(
model_name='complaintupdate',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_updates', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='escalationrule',
name='escalate_to_user',
field=models.ForeignKey(blank=True, help_text="Specific user if escalate_to_role is 'specific_user'", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalation_target_rules', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='escalationrule',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='escalation_rules', to='organizations.hospital'),
),
migrations.AddField(
model_name='inquiry',
name='assigned_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_inquiries', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='inquiry',
name='department',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.department'),
),
migrations.AddField(
model_name='inquiry',
name='hospital',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.hospital'),
),
migrations.AddField(
model_name='inquiry',
name='patient',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.patient'),
),
migrations.AddField(
model_name='inquiry',
name='responded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responded_inquiries', to=settings.AUTH_USER_MODEL),
),
migrations.AddIndex(
model_name='complaintcategory',
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_a31674_idx'),
),
migrations.AddIndex(
model_name='complaintcategory',
index=models.Index(fields=['code'], name='complaints__code_8e9bbe_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['status', '-created_at'], name='complaints__status_f077e8_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='complaints__hospita_cf53df_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['is_overdue', 'status'], name='complaints__is_over_3d3554_idx'),
),
migrations.AddIndex(
model_name='complaint',
index=models.Index(fields=['due_at', 'status'], name='complaints__due_at_836821_idx'),
),
migrations.AddIndex(
model_name='complaintslaconfig',
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_bdf8a5_idx'),
),
migrations.AlterUniqueTogether(
name='complaintslaconfig',
unique_together={('hospital', 'severity', 'priority')},
),
migrations.AddIndex(
model_name='complaintthreshold',
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_b8efc9_idx'),
),
migrations.AddIndex(
model_name='complaintthreshold',
index=models.Index(fields=['threshold_type', 'is_active'], name='complaints__thresho_719969_idx'),
),
migrations.AddIndex(
model_name='complaintupdate',
index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_f3684e_idx'),
),
migrations.AddIndex(
model_name='escalationrule',
index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_3c8bac_idx'),
),
migrations.AddIndex(
model_name='inquiry',
index=models.Index(fields=['status', '-created_at'], name='complaints__status_3d0678_idx'),
),
migrations.AddIndex(
model_name='inquiry',
index=models.Index(fields=['hospital', 'status'], name='complaints__hospita_b1573b_idx'),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.0.14 on 2026-01-05 13:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0002_initial'),
('organizations', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='complaintcategory',
options={'ordering': ['order', 'name_en'], 'verbose_name_plural': 'Complaint Categories'},
),
migrations.RemoveIndex(
model_name='complaintcategory',
name='complaints__hospita_a31674_idx',
),
migrations.RemoveField(
model_name='complaintcategory',
name='hospital',
),
migrations.AddField(
model_name='complaintcategory',
name='hospitals',
field=models.ManyToManyField(blank=True, help_text='Empty list = system-wide category. Add hospitals to share category.', related_name='complaint_categories', to='organizations.hospital'),
),
]

View File

@ -14,7 +14,7 @@ from django.conf import settings
from django.db import models
from django.utils import timezone
from apps.core.models import PriorityChoices, SeverityChoices, TimeStampedModel, UUIDModel
from apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel
class ComplaintStatus(models.TextChoices):
@ -39,6 +39,64 @@ 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.
@ -57,9 +115,27 @@ 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,
@ -80,8 +156,8 @@ 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,
@ -93,18 +169,12 @@ class Complaint(UUIDModel, TimeStampedModel):
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)
@ -201,7 +271,7 @@ class Complaint(UUIDModel, TimeStampedModel):
]
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"""
@ -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.)"""
@ -375,59 +556,6 @@ class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
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):

View File

@ -20,16 +20,21 @@ 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))

View File

@ -6,6 +6,7 @@ This module contains tasks for:
- Sending SLA reminders
- Triggering resolution satisfaction surveys
- Creating PX actions from complaints
- AI-powered complaint analysis
"""
import logging
@ -292,12 +293,20 @@ def create_action_from_complaint(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)
@ -313,7 +322,8 @@ 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,
}
)
@ -499,6 +509,182 @@ 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):
"""

View File

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

View File

@ -0,0 +1,33 @@
"""
Math template filters for complaints
"""
from django import template
register = template.Library()
@register.filter
def mul(value, arg):
"""Multiply the value by the argument"""
try:
return float(value) * float(arg)
except (ValueError, TypeError):
return 0
@register.filter
def div(value, arg):
"""Divide the value by the argument"""
try:
return float(value) / float(arg)
except (ValueError, TypeError, ZeroDivisionError):
return 0
@register.filter
def sub(value, arg):
"""Subtract the argument from the value"""
try:
return float(value) - float(arg)
except (ValueError, TypeError):
return 0

View File

@ -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,
@ -39,7 +40,7 @@ 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'
)
@ -85,9 +86,9 @@ def complaint_list(request):
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:
@ -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',
@ -239,7 +240,7 @@ def complaint_detail(request, pk):
@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:
@ -249,38 +250,53 @@ def complaint_create(request):
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)
staff_id = request.POST.get('staff_id', None)
title = request.POST.get('title')
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,15 +304,19 @@ 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')
@ -488,7 +508,7 @@ def complaint_export_csv(request):
# 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'
)
@ -548,7 +568,7 @@ def complaint_export_excel(request):
# 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'
)
@ -796,6 +816,7 @@ def inquiry_list(request):
@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'
@ -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,
@ -1075,6 +1110,7 @@ def inquiry_respond(request, pk):
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',
@ -1139,7 +1178,245 @@ def complaints_analytics(request):
# ============================================================================
# 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
@ -1158,19 +1435,18 @@ def get_departments_by_hospital(request):
@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': []})
return JsonResponse({'staff': []})
from apps.organizations.models import Physician
physicians = Physician.objects.filter(
staff_members = Staff.objects.filter(
department_id=department_id,
status='active'
).values('id', 'first_name', 'last_name')
).values('id', 'first_name', 'last_name', 'staff_type', 'job_title')
return JsonResponse({'physicians': list(physicians)})
return JsonResponse({'staff': list(staff_members)})
@login_required

View File

@ -44,9 +44,16 @@ urlpatterns = [
# AJAX Helpers
path('ajax/departments/', ui_views.get_departments_by_hospital, name='get_departments_by_hospital'),
path('ajax/physicians/', ui_views.get_physicians_by_department, name='get_physicians_by_department'),
path('ajax/physicians/', ui_views.get_staff_by_department, name='get_physicians_by_department'),
path('ajax/search-patients/', ui_views.search_patients, name='search_patients'),
# Public Complaint Form (No Authentication Required)
path('public/submit/', ui_views.public_complaint_submit, name='public_complaint_submit'),
path('public/success/<str:reference>/', ui_views.public_complaint_success, name='public_complaint_success'),
path('public/api/lookup-patient/', ui_views.api_lookup_patient, name='api_lookup_patient'),
path('public/api/load-departments/', ui_views.api_load_departments, name='api_load_departments'),
path('public/api/load-categories/', ui_views.api_load_categories, name='api_load_categories'),
# API Routes
path('', include(router.urls)),
]

View File

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

584
apps/core/ai_service.py Normal file
View File

@ -0,0 +1,584 @@
"""
AI Service - Base class for all AI interactions using LiteLLM
This module provides a unified interface for AI operations using LiteLLM
with OpenRouter as the provider. This replaces the stub AI engine.
Features:
- Complaint analysis (severity, priority classification)
- Chat completion for general AI tasks
- Sentiment analysis
- Entity extraction
- Language detection
"""
import os
import json
import logging
from typing import Dict, List, Optional, Any
from django.conf import settings
from django.core.cache import cache
logger = logging.getLogger(__name__)
class AIServiceError(Exception):
"""Custom exception for AI service errors"""
pass
class AIService:
"""
Base AI Service class using LiteLLM with OpenRouter.
This is the single source of truth for all AI interactions in the application.
"""
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_API_KEY = "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"
# Default configuration
DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
DEFAULT_TEMPERATURE = 0.3
DEFAULT_MAX_TOKENS = 500
DEFAULT_TIMEOUT = 30
# Severity choices
SEVERITY_CHOICES = ['low', 'medium', 'high', 'critical']
# Priority choices
PRIORITY_CHOICES = ['low', 'medium', 'high']
@classmethod
def _get_api_key(cls) -> str:
"""Get OpenRouter API key from settings"""
# Use 'or' operator to fall back to DEFAULT_API_KEY when setting is empty or not set
api_key = cls.OPENROUTER_API_KEY
os.environ["OPENROUTER_API_KEY"] = api_key
os.environ["OPENROUTER_API_BASE"] = cls.OPENROUTER_BASE_URL
return api_key
@classmethod
def _get_model(cls) -> str:
"""Get AI model from settings"""
return getattr(settings, 'AI_MODEL') or cls.DEFAULT_MODEL
@classmethod
def _get_temperature(cls) -> float:
"""Get AI temperature from settings"""
return float(getattr(settings, 'AI_TEMPERATURE')) or cls.DEFAULT_TEMPERATURE
@classmethod
def _get_max_tokens(cls) -> int:
"""Get max tokens from settings"""
return int(getattr(settings, 'AI_MAX_TOKENS')) or cls.DEFAULT_MAX_TOKENS
@classmethod
def _get_complaint_categories(cls) -> List[str]:
"""Get complaint categories from settings"""
from apps.complaints.models import ComplaintCategory
return ComplaintCategory.objects.all().values_list('name_en', flat=True)
@classmethod
def _get_complaint_sub_categories(cls, category) -> List[str]:
"""Get complaint subcategories for a given category name"""
from apps.complaints.models import ComplaintCategory
if category:
try:
# Find the category by name and get its subcategories
category_obj = ComplaintCategory.objects.filter(name_en=category).first()
if category_obj:
return ComplaintCategory.objects.filter(parent=category_obj).values_list('name_en', flat=True)
except Exception as e:
logger.error(f"Error fetching subcategories: {e}")
return []
@classmethod
def _get_all_categories_with_subcategories(cls) -> Dict[str, List[str]]:
"""Get all categories with their subcategories in a structured format"""
from apps.complaints.models import ComplaintCategory
result = {}
try:
# Get all parent categories (no parent or parent is null)
parent_categories = ComplaintCategory.objects.filter(parent__isnull=True).all()
for category in parent_categories:
# Get subcategories for this parent
subcategories = list(
ComplaintCategory.objects.filter(parent=category).values_list('name_en', flat=True)
)
result[category.name_en] = subcategories if subcategories else []
except Exception as e:
logger.error(f"Error fetching categories with subcategories: {e}")
return result
@classmethod
def _get_hospital_departments(cls, hospital_id: int) -> List[str]:
"""Get all departments for a specific hospital"""
from apps.organizations.models import Department
try:
departments = Department.objects.filter(
hospital_id=hospital_id,
status='active'
).values_list('name', flat=True)
return list(departments)
except Exception as e:
logger.error(f"Error fetching hospital departments: {e}")
return []
@classmethod
def chat_completion(
cls,
prompt: str,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
response_format: Optional[str] = None
) -> str:
"""
Perform a chat completion using LiteLLM.
Args:
prompt: User prompt
model: AI model (uses default if not provided)
temperature: Temperature for randomness (uses default if not provided)
max_tokens: Maximum tokens to generate
system_prompt: System prompt to set context
response_format: Response format ('text' or 'json_object')
Returns:
Generated text response
Raises:
AIServiceError: If API call fails
"""
try:
from litellm import completion
api_key = cls._get_api_key()
model_name = model or cls._get_model()
temp = temperature if temperature is not None else cls._get_temperature()
max_tok = max_tokens or cls._get_max_tokens()
# Build messages
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
# Build kwargs
kwargs = {
"model": "openrouter/xiaomi/mimo-v2-flash:free",
"messages": messages
}
if response_format:
kwargs["response_format"] = {"type": response_format}
logger.info(f"AI Request: model={model_name}, temp={temp}")
response = completion(**kwargs)
content = response.choices[0].message.content
logger.info(f"AI Response: length={len(content)}")
return content
except Exception as e:
logger.error(f"AI service error: {str(e)}")
raise AIServiceError(f"Failed to get AI response: {str(e)}")
@classmethod
def analyze_complaint(
cls,
title: Optional[str] = None,
description: str = "",
category: Optional[str] = None,
hospital_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Analyze a complaint and determine title, severity, priority, category, subcategory, and department.
Args:
title: Complaint title (optional, will be generated if not provided)
description: Complaint description
category: Complaint category
hospital_id: Hospital ID to fetch departments
Returns:
Dictionary with analysis:
{
'title': str, # Generated or provided title
'short_description': str, # 2-3 sentence summary of the complaint
'severity': 'low' | 'medium' | 'high' | 'critical',
'priority': 'low' | 'medium' | 'high',
'category': str, # Name of the category
'subcategory': str, # Name of the subcategory
'department': str, # Name of the department
'reasoning': str # Explanation for the classification
}
"""
# Check cache first
cache_key = f"complaint_analysis:{hash(str(title) + description + str(hospital_id))}"
cached_result = cache.get(cache_key)
if cached_result:
logger.info("Using cached complaint analysis")
return cached_result
# Get categories with subcategories
categories_with_subcategories = cls._get_all_categories_with_subcategories()
# Format categories for the prompt
categories_text = ""
for cat, subcats in categories_with_subcategories.items():
if subcats:
categories_text += f"- {cat} (subcategories: {', '.join(subcats)})\n"
else:
categories_text += f"- {cat}\n"
# Get hospital departments if hospital_id is provided
departments_text = ""
if hospital_id:
departments = cls._get_hospital_departments(hospital_id)
if departments:
departments_text = f"\nAvailable Departments for this hospital:\n"
for dept in departments:
departments_text += f"- {dept}\n"
departments_text += "\n"
# Build prompt
title_text = f"Complaint Title: {title}\n" if title else ""
prompt = f"""Analyze this complaint and classify its severity, priority, category, subcategory, and department.
Complaint Description: {description}
{title_text}Current Category: {category or 'not specified'}{departments_text}Severity Classification (choose one):
- low: Minor issues, no impact on patient care, routine matters
- medium: Moderate issues, some patient dissatisfaction, not urgent
- high: Serious issues, significant patient impact, requires timely attention
- critical: Emergency, immediate threat to patient safety, requires instant action
Priority Classification (choose one):
- low: Can be addressed within 1-2 weeks
- medium: Should be addressed within 3-5 days
- high: Requires immediate attention (within 24 hours)
Available Categories and Subcategories:
{categories_text}
Instructions:
1. If no title is provided, generate a concise title (max 10 words) that summarizes the complaint in BOTH English and Arabic
2. Generate a short_description (2-3 sentences) that captures the main issue and context in BOTH English and Arabic
3. Select the most appropriate category from the list above
4. If the selected category has subcategories, choose the most relevant one
5. If a category has no subcategories, leave the subcategory field empty
6. Select the most appropriate department from the hospital's departments (if available)
7. If no departments are available or department is unclear, leave the department field empty
8. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic
IMPORTANT: ALL TEXT FIELDS MUST BE PROVIDED IN BOTH ENGLISH AND ARABIC
- title: Provide in both English and Arabic
- short_description: Provide in both English and Arabic
- suggested_action: Provide in both English and Arabic
- reasoning: Provide in both English and Arabic
Provide your analysis in JSON format:
{{
"title_en": "concise title in English summarizing the complaint (max 10 words)",
"title_ar": "العنوان بالعربية",
"short_description_en": "2-3 sentence summary in English of the complaint that captures the main issue and context",
"short_description_ar": "ملخص من 2-3 جمل بالعربية",
"severity": "low|medium|high|critical",
"priority": "low|medium|high",
"category": "exact category name from the list above",
"subcategory": "exact subcategory name from the chosen category, or empty string if not applicable",
"department": "exact department name from the hospital's departments, or empty string if not applicable",
"suggested_action_en": "2-3 specific, actionable steps in English to address this complaint",
"suggested_action_ar": "خطوات محددة وعمليه بالعربية",
"reasoning_en": "Brief explanation in English of your classification (2-3 sentences)",
"reasoning_ar": "شرح مختصر بالعربية"
}}"""
system_prompt = """You are a healthcare complaint analysis expert fluent in both English and Arabic.
Your job is to classify complaints based on their potential impact on patient care and safety.
Be conservative - when in doubt, choose a higher severity/priority.
Generate clear, concise titles that accurately summarize the complaint in BOTH English and Arabic.
Provide all text fields in both languages."""
try:
response = cls.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
response_format="json_object",
temperature=0.2 # Lower temperature for consistent classification
)
# Parse JSON response
result = json.loads(response)
# Use provided title if available, otherwise use AI-generated title
if title:
result['title'] = title
# Validate severity
if result.get('severity') not in cls.SEVERITY_CHOICES:
result['severity'] = 'medium'
logger.warning(f"Invalid severity, defaulting to medium")
# Validate priority
if result.get('priority') not in cls.PRIORITY_CHOICES:
result['priority'] = 'medium'
logger.warning(f"Invalid priority, defaulting to medium")
# Validate category
if result.get('category') not in cls._get_complaint_categories():
result['category'] = 'other'
logger.warning(f"Invalid category, defaulting to 'Not specified'")
# Ensure title exists
if not result.get('title'):
result['title'] = 'Complaint'
# Cache result for 1 hour
cache.set(cache_key, result, timeout=3600)
logger.info(f"Complaint analyzed: title={result['title']}, severity={result['severity']}, priority={result['priority']}, department={result.get('department', 'N/A')}")
return result
except json.JSONDecodeError as e:
logger.error(f"Failed to parse AI response: {e}")
# Return defaults
return {
'title': title or 'Complaint',
'severity': 'medium',
'priority': 'medium',
'category': 'other',
'subcategory': '',
'department': '',
'reasoning': 'AI analysis failed, using default values'
}
except AIServiceError as e:
logger.error(f"AI service error: {e}")
return {
'title': title or 'Complaint',
'severity': 'medium',
'priority': 'medium',
'category': 'other',
'subcategory': '',
'department': '',
'reasoning': f'AI service unavailable: {str(e)}'
}
@classmethod
def classify_sentiment(
cls,
text: str
) -> Dict[str, Any]:
"""
Classify sentiment of text.
Args:
text: Text to analyze
Returns:
Dictionary with sentiment analysis:
{
'sentiment': 'positive' | 'neutral' | 'negative',
'score': float, # -1.0 to 1.0
'confidence': float # 0.0 to 1.0
}
"""
prompt = f"""Analyze the sentiment of this text:
{text}
Provide your analysis in JSON format:
{{
"sentiment": "positive|neutral|negative",
"score": float, # -1.0 (very negative) to 1.0 (very positive)
"confidence": float # 0.0 to 1.0
}}"""
system_prompt = """You are a sentiment analysis expert.
Analyze the emotional tone of the text accurately."""
try:
response = cls.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
response_format="json_object",
temperature=0.1
)
result = json.loads(response)
return result
except (json.JSONDecodeError, AIServiceError) as e:
logger.error(f"Sentiment analysis failed: {e}")
return {
'sentiment': 'neutral',
'score': 0.0,
'confidence': 0.0
}
@classmethod
def analyze_emotion(
cls,
text: str
) -> Dict[str, Any]:
"""
Analyze emotion in text to identify primary emotion and intensity.
Args:
text: Text to analyze (supports English and Arabic)
Returns:
Dictionary with emotion analysis:
{
'emotion': 'anger' | 'sadness' | 'confusion' | 'fear' | 'neutral',
'intensity': float, # 0.0 to 1.0 (how strong the emotion is)
'confidence': float # 0.0 to 1.0 (how confident AI is)
}
"""
prompt = f"""Analyze the primary emotion in this text (supports English and Arabic):
{text}
Identify the PRIMARY emotion from these options:
- anger: Strong feelings of displeasure, hostility, or rage
- sadness: Feelings of sorrow, grief, or unhappiness
- confusion: Lack of understanding, bewilderment, or uncertainty
- fear: Feelings of anxiety, worry, or being afraid
- neutral: No strong emotion detected
Provide your analysis in JSON format:
{{
"emotion": "anger|sadness|confusion|fear|neutral",
"intensity": float, # 0.0 (very weak) to 1.0 (extremely strong)
"confidence": float # 0.0 to 1.0 (how confident you are)
}}
Examples:
- "This is unacceptable! I demand to speak to management!" -> emotion: "anger", intensity: 0.9
- "I'm very disappointed with the care my father received" -> emotion: "sadness", intensity: 0.7
- "I don't understand what happened, can you explain?" -> emotion: "confusion", intensity: 0.5
- "I'm worried about the side effects of this medication" -> emotion: "fear", intensity: 0.6
- "I would like to report a minor issue" -> emotion: "neutral", intensity: 0.2
"""
system_prompt = """You are an emotion analysis expert fluent in both English and Arabic.
Analyze the text to identify the PRIMARY emotion and its intensity.
Be accurate in distinguishing between different emotions.
Provide intensity scores that reflect how strongly the emotion is expressed (0.0 to 1.0)."""
try:
response = cls.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
response_format="json_object",
temperature=0.1
)
result = json.loads(response)
# Validate emotion
valid_emotions = ['anger', 'sadness', 'confusion', 'fear', 'neutral']
if result.get('emotion') not in valid_emotions:
result['emotion'] = 'neutral'
logger.warning(f"Invalid emotion detected, defaulting to neutral")
# Validate intensity
intensity = float(result.get('intensity', 0.0))
if not (0.0 <= intensity <= 1.0):
intensity = max(0.0, min(1.0, intensity))
result['intensity'] = intensity
logger.warning(f"Intensity out of range, clamping to {intensity}")
# Validate confidence
confidence = float(result.get('confidence', 0.0))
if not (0.0 <= confidence <= 1.0):
confidence = max(0.0, min(1.0, confidence))
result['confidence'] = confidence
logger.warning(f"Confidence out of range, clamping to {confidence}")
logger.info(f"Emotion analysis: {result['emotion']}, intensity={intensity}, confidence={confidence}")
return result
except (json.JSONDecodeError, AIServiceError) as e:
logger.error(f"Emotion analysis failed: {e}")
return {
'emotion': 'neutral',
'intensity': 0.0,
'confidence': 0.0
}
@classmethod
def extract_entities(cls, text: str) -> List[Dict[str, str]]:
prompt = f"""Extract named entities from this text:
"{text}"
Focus heavily on PERSON names.
IMPORTANT: Extract the clean name only. Remove titles like 'Dr.', 'Nurse', 'Mr.', 'Professor', 'دكتور', 'ممرض'.
Provide entities in JSON format:
{{
"entities": [
{{"text": "Name", "type": "PERSON"}},
{{"text": "DepartmentName", "type": "ORGANIZATION"}}
]
}}"""
system_prompt = "You are an expert in bilingual NER (Arabic and English). Extract formal names for database lookup."
try:
response = cls.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
response_format="json_object",
temperature=0.0
)
return json.loads(response).get('entities', [])
except (json.JSONDecodeError, AIServiceError):
return []
@classmethod
def generate_summary(cls, text: str, max_length: int = 200) -> str:
"""
Generate a summary of text.
Args:
text: Text to summarize
max_length: Maximum length of summary
Returns:
Summary text
"""
prompt = f"""Summarize this text in {max_length} characters or less:
{text}"""
system_prompt = """You are a text summarization expert.
Create a concise summary that captures the main points."""
try:
response = cls.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
temperature=0.3,
max_tokens=150
)
return response.strip()
except AIServiceError as e:
logger.error(f"Summary generation failed: {e}")
return text[:max_length]
# Convenience singleton instance
ai_service = AIService()

View File

@ -23,17 +23,27 @@ def sidebar_counts(request):
user = request.user
# Filter based on user role
# Filter based on user role and tenant_hospital
if user.is_px_admin():
complaint_count = Complaint.objects.filter(
status__in=['open', 'in_progress']
).count()
feedback_count = Feedback.objects.filter(
status__in=['submitted', 'reviewed']
).count()
action_count = PXAction.objects.filter(
status__in=['open', 'in_progress']
).count()
# PX Admins use their selected hospital from session
hospital = getattr(request, 'tenant_hospital', None)
if hospital:
complaint_count = Complaint.objects.filter(
hospital=hospital,
status__in=['open', 'in_progress']
).count()
feedback_count = Feedback.objects.filter(
hospital=hospital,
status__in=['submitted', 'reviewed']
).count()
action_count = PXAction.objects.filter(
hospital=hospital,
status__in=['open', 'in_progress']
).count()
else:
complaint_count = 0
feedback_count = 0
action_count = 0
# Count provisional users for PX Admin
from apps.accounts.models import User
provisional_user_count = User.objects.filter(
@ -58,11 +68,29 @@ def sidebar_counts(request):
complaint_count = 0
feedback_count = 0
action_count = 0
provisional_user_count = 0
return {
'complaint_count': complaint_count,
'feedback_count': feedback_count,
'action_count': action_count,
'current_hospital': getattr(request, 'tenant_hospital', None),
'is_px_admin': request.user.is_authenticated and request.user.is_px_admin(),
}
def hospital_context(request):
"""
Provide current hospital context to templates.
This ensures hospital information is available in the header for all pages.
"""
if not request.user.is_authenticated:
return {}
hospital = getattr(request, 'tenant_hospital', None)
return {
'current_hospital': hospital,
'is_px_admin': request.user.is_px_admin(),
'provisional_user_count': provisional_user_count,
}

31
apps/core/managers.py Normal file
View File

@ -0,0 +1,31 @@
"""
Tenant-aware managers and querysets for multi-tenancy
"""
from django.db import models
class TenantQuerySet(models.QuerySet):
"""QuerySet that automatically filters by current tenant."""
def for_tenant(self, hospital):
"""Filter records for specific hospital."""
return self.filter(hospital=hospital)
def for_current_tenant(self, request):
"""Filter records for current request's tenant hospital."""
if hasattr(request, 'tenant_hospital') and request.tenant_hospital:
return self.filter(hospital=request.tenant_hospital)
return self
class TenantManager(models.Manager):
"""Manager that uses TenantQuerySet."""
def get_queryset(self):
return TenantQuerySet(self.model, using=self._db)
def for_tenant(self, hospital):
return self.get_queryset().for_tenant(hospital)
def for_current_tenant(self, request):
return self.get_queryset().for_current_tenant(request)

46
apps/core/middleware.py Normal file
View File

@ -0,0 +1,46 @@
"""
Tenant-aware middleware for multi-tenancy
"""
from django.utils.deprecation import MiddlewareMixin
class TenantMiddleware(MiddlewareMixin):
"""
Middleware that sets the current hospital context from the authenticated user.
This middleware ensures that:
- authenticated users have their tenant_hospital set from their profile
- PX admins can switch between hospitals via session
- All requests have tenant context available
"""
def process_request(self, request):
"""Set tenant hospital context on each request."""
if request.user and request.user.is_authenticated:
# Store user's role for quick access
request.user_roles = request.user.get_role_names()
# PX Admins can switch hospitals via session
if request.user.is_px_admin():
hospital_id = request.session.get('selected_hospital_id')
if hospital_id:
from apps.organizations.models import Hospital
try:
# Validate that the hospital exists
request.tenant_hospital = Hospital.objects.get(id=hospital_id)
except Hospital.DoesNotExist:
# Invalid hospital ID, fall back to default
request.tenant_hospital = None
# Clear invalid session data
request.session.pop('selected_hospital_id', None)
else:
# No hospital selected yet
request.tenant_hospital = None
else:
# Non-PX Admin users use their assigned hospital
request.tenant_hospital = request.user.hospital
else:
request.tenant_hospital = None
request.user_roles = []
return None

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:07
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid

185
apps/core/mixins.py Normal file
View File

@ -0,0 +1,185 @@
"""
Tenant-aware mixins for views and serializers
"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import redirect
from rest_framework import serializers
class TenantAccessMixin:
"""
Mixin that validates hospital access for views.
This mixin ensures:
- Users can only access objects from their hospital
- PX admins can access all hospitals
- Hospital admins can only access their hospital
- Department managers can only access their department
"""
def get_object(self, queryset=None):
"""Retrieve object with tenant validation."""
obj = super().get_object(queryset)
# Check if user has access to this object's hospital
if hasattr(obj, 'hospital'):
if not self.can_access_hospital(obj.hospital):
raise PermissionDenied("You don't have access to this hospital's data")
return obj
def get_queryset(self):
"""Filter queryset based on user's hospital and role."""
queryset = super().get_queryset()
user = self.request.user
# PX Admins can see all hospitals
if user.is_px_admin():
return queryset
# Users without a hospital cannot see any records
if not user.hospital:
return queryset.none()
# Filter by user's hospital
queryset = queryset.filter(hospital=user.hospital)
# Department managers can only see their department's records
if user.is_department_manager() and user.department:
if hasattr(queryset.model, 'department'):
queryset = queryset.filter(department=user.department)
return queryset
def can_access_hospital(self, hospital):
"""Check if user can access given hospital."""
user = self.request.user
# PX Admins can access all hospitals
if user.is_px_admin():
return True
# Users can only access their own hospital
if user.hospital == hospital:
return True
return False
class TenantSerializerMixin:
"""
Mixin that validates hospital field in serializers.
This mixin ensures:
- Users can only create records for their hospital
- PX admins can create records for any hospital
- Hospital field is validated and set automatically
"""
def validate_hospital(self, value):
"""Ensure user can create records for this hospital."""
user = self.context['request'].user
# PX admins can assign to any hospital
if user.is_px_admin():
return value
# Users must create records for their own hospital
if user.hospital != value:
raise serializers.ValidationError(
"You can only create records for your hospital"
)
return value
def to_internal_value(self, data):
"""Set hospital from user's profile if not provided."""
# Convert data to mutable dict if needed
mutable_data = data.copy() if hasattr(data, 'copy') else data
user = self.context['request'].user
# Auto-set hospital if not provided and user has one
if 'hospital' not in mutable_data or not mutable_data['hospital']:
if user.hospital:
mutable_data['hospital'] = str(user.hospital.id)
return super().to_internal_value(mutable_data)
class TenantAdminMixin:
"""
Mixin for Django admin with tenant isolation.
This mixin ensures:
- Admin users only see their hospital's records
- PX admins see all records
- New records are automatically assigned to user's hospital
"""
def get_queryset(self, request):
"""Filter queryset based on user's hospital."""
qs = super().get_queryset(request)
# PX Admins can see all hospitals
if request.user.is_px_admin():
return qs
# Users with a hospital can only see their hospital's records
if request.user.hospital:
qs = qs.filter(hospital=request.user.hospital)
return qs
def save_model(self, request, obj, form, change):
"""Auto-assign hospital on create."""
if not change and hasattr(obj, 'hospital') and not obj.hospital:
obj.hospital = request.user.hospital
super().save_model(request, obj, form, change)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Limit foreign key choices to user's hospital."""
if db_field.name == 'hospital':
# Only PX admins can select any hospital
if not request.user.is_px_admin():
# Filter to user's hospital
kwargs['queryset'] = db_field.related_model.objects.filter(
id=request.user.hospital.id
)
# Filter department choices to user's hospital
if db_field.name == 'department':
kwargs['queryset'] = db_field.related_model.objects.filter(
hospital=request.user.hospital
)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class TenantRequiredMixin(LoginRequiredMixin):
"""
Mixin that ensures user has hospital context.
This mixin ensures:
- User is authenticated (from LoginRequiredMixin)
- User has a hospital assigned (or is PX Admin)
- Redirects PX Admins to hospital selector if no hospital selected
- Redirects other users to error page if no hospital assigned
"""
def dispatch(self, request, *args, **kwargs):
"""Check hospital context before processing request."""
response = super().dispatch(request, *args, **kwargs)
# PX Admins need to select a hospital
if request.user.is_px_admin():
if not request.tenant_hospital:
return redirect('core:select_hospital')
# Other users must have a hospital assigned
elif not request.user.hospital:
return redirect('core:no_hospital_assigned')
return response

View File

@ -141,3 +141,23 @@ class SeverityChoices(BaseChoices):
MEDIUM = 'medium', 'Medium'
HIGH = 'high', 'High'
CRITICAL = 'critical', 'Critical'
class TenantModel(models.Model):
"""
Abstract base model for tenant-aware models.
Automatically filters by current hospital context.
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='%(app_label)s_%(class)s_related',
db_index=True,
help_text="Tenant hospital for this record"
)
class Meta:
abstract = True
indexes = [
models.Index(fields=['hospital']),
]

View File

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

View File

@ -0,0 +1,19 @@
"""
Template filters for hospital-related functionality
"""
from django import template
register = template.Library()
@register.simple_tag
def get_all_hospitals():
"""
Get all hospitals for the hospital switcher dropdown.
This is used by PX Admins to quickly switch between hospitals
directly from the header without navigating to the selector page.
"""
from apps.organizations.models import Hospital
return Hospital.objects.all().order_by('name', 'city')

View File

@ -3,13 +3,15 @@ Core app URLs
"""
from django.urls import path
from .views import health_check
from .views import health_check, select_hospital, no_hospital_assigned
from . import config_views
app_name = 'core'
urlpatterns = [
path('', health_check, name='health'),
path('select-hospital/', select_hospital, name='select_hospital'),
path('no-hospital/', no_hospital_assigned, name='no_hospital_assigned'),
]
# Configuration URLs (separate app_name)

View File

@ -1,10 +1,12 @@
"""
Core views - Health check and utility views
"""
from django.contrib.auth.decorators import login_required
from django.db import connection
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
from django.views.decorators.http import require_GET, require_POST
@never_cache
@ -41,3 +43,50 @@ def health_check(request):
return JsonResponse(health_status, status=status_code)
@login_required
def select_hospital(request):
"""
Hospital selection page for PX Admins.
Allows PX Admins to switch between hospitals.
Stores selected hospital in session.
"""
# Only PX Admins should access this page
if not request.user.is_px_admin():
return redirect('dashboard:dashboard')
from apps.organizations.models import Hospital
hospitals = Hospital.objects.all().order_by('name')
# Handle hospital selection
if request.method == 'POST':
hospital_id = request.POST.get('hospital_id')
if hospital_id:
try:
hospital = Hospital.objects.get(id=hospital_id)
request.session['selected_hospital_id'] = str(hospital.id)
# Redirect to referring page or dashboard
next_url = request.POST.get('next', request.GET.get('next', '/'))
return redirect(next_url)
except Hospital.DoesNotExist:
pass
context = {
'hospitals': hospitals,
'selected_hospital_id': request.session.get('selected_hospital_id'),
'next': request.GET.get('next', '/'),
}
return render(request, 'core/select_hospital.html', context)
@login_required
def no_hospital_assigned(request):
"""
Error page for users without a hospital assigned.
Users without a hospital assignment cannot access the system.
"""
return render(request, 'core/no_hospital_assigned.html', status=403)

View File

@ -5,6 +5,7 @@ from datetime import timedelta
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Avg, Count, Q
from django.shortcuts import redirect
from django.utils import timezone
from django.views.generic import TemplateView
from django.utils.translation import gettext_lazy as _
@ -23,6 +24,14 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
"""
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
@ -35,7 +44,7 @@ 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()
@ -43,13 +52,15 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
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)
@ -136,22 +147,22 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
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)
current_month_ratings = current_month_ratings.filter(staff__department=user.department)
# Top 5 physicians this month
# Top 5 staff this month
context['top_physicians'] = current_month_ratings.order_by('-average_rating')[:5]
# Physician stats
# Staff stats
physician_stats = current_month_ratings.aggregate(
total_physicians=Count('id'),
avg_rating=Avg('average_rating'),
@ -166,6 +177,10 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
'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):

View File

@ -3,7 +3,7 @@ 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
@ -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={
@ -124,13 +124,13 @@ class FeedbackForm(forms.ModelForm):
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'):

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-24 10:22
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid
@ -16,6 +16,39 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='FeedbackAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='feedback/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FeedbackResponse',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('response_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Internal Note'), ('response', 'Response to Patient'), ('acknowledgment', 'Acknowledgment')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('is_internal', models.BooleanField(default=False, help_text='Internal note (not visible to patient)')),
('metadata', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Feedback',
fields=[
@ -27,7 +60,7 @@ class Migration(migrations.Migration):
('contact_email', models.EmailField(blank=True, max_length=254)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)),
('feedback_type', models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry')], db_index=True, default='general', max_length=20)),
('feedback_type', models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry'), ('satisfaction_check', 'Satisfaction Check')], db_index=True, default='general', max_length=20)),
('title', models.CharField(max_length=500)),
('message', models.TextField(help_text='Feedback message')),
('category', models.CharField(choices=[('clinical_care', 'Clinical Care'), ('staff_service', 'Staff Service'), ('facility', 'Facility & Environment'), ('communication', 'Communication'), ('appointment', 'Appointment & Scheduling'), ('billing', 'Billing & Insurance'), ('food_service', 'Food Service'), ('cleanliness', 'Cleanliness'), ('technology', 'Technology & Systems'), ('other', 'Other')], db_index=True, max_length=50)),
@ -55,73 +88,10 @@ class Migration(migrations.Migration):
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.hospital')),
('patient', models.ForeignKey(blank=True, help_text='Patient who provided feedback (optional for anonymous feedback)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.patient')),
('physician', models.ForeignKey(blank=True, help_text='Physician being mentioned in feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.physician')),
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_feedbacks', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Feedback',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FeedbackAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='feedback/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='feedback.feedback')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_attachments', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FeedbackResponse',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('response_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Internal Note'), ('response', 'Response to Patient'), ('acknowledgment', 'Acknowledgment')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('is_internal', models.BooleanField(default=False, help_text='Internal note (not visible to patient)')),
('metadata', models.JSONField(blank=True, default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_responses', to=settings.AUTH_USER_MODEL)),
('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='feedback.feedback')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['status', '-created_at'], name='feedback_fe_status_212662_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='feedback_fe_hospita_4c1146_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['feedback_type', '-created_at'], name='feedback_fe_feedbac_6b63a4_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['sentiment', '-created_at'], name='feedback_fe_sentime_443190_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['is_deleted', '-created_at'], name='feedback_fe_is_dele_f543d5_idx'),
),
migrations.AddIndex(
model_name='feedbackresponse',
index=models.Index(fields=['feedback', '-created_at'], name='feedback_fe_feedbac_bc9e33_idx'),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 5.0.14 on 2025-12-28 16:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feedback', '0001_initial'),
('surveys', '0003_add_survey_linkage'),
]
operations = [
migrations.AddField(
model_name='feedback',
name='related_survey',
field=models.ForeignKey(blank=True, help_text='Survey that triggered this satisfaction check feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='follow_up_feedbacks', to='surveys.surveyinstance'),
),
migrations.AlterField(
model_name='feedback',
name='feedback_type',
field=models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry'), ('satisfaction_check', 'Satisfaction Check')], db_index=True, default='general', max_length=20),
),
]

View File

@ -0,0 +1,79 @@
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('feedback', '0001_initial'),
('organizations', '0001_initial'),
('surveys', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='feedback',
name='related_survey',
field=models.ForeignKey(blank=True, help_text='Survey that triggered this satisfaction check feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='follow_up_feedbacks', to='surveys.surveyinstance'),
),
migrations.AddField(
model_name='feedback',
name='reviewed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_feedbacks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='feedback',
name='staff',
field=models.ForeignKey(blank=True, help_text='Staff member being mentioned in feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.staff'),
),
migrations.AddField(
model_name='feedbackattachment',
name='feedback',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='feedback.feedback'),
),
migrations.AddField(
model_name='feedbackattachment',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_attachments', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='feedbackresponse',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_responses', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='feedbackresponse',
name='feedback',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='feedback.feedback'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['status', '-created_at'], name='feedback_fe_status_212662_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='feedback_fe_hospita_4c1146_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['feedback_type', '-created_at'], name='feedback_fe_feedbac_6b63a4_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['sentiment', '-created_at'], name='feedback_fe_sentime_443190_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['is_deleted', '-created_at'], name='feedback_fe_is_dele_f543d5_idx'),
),
migrations.AddIndex(
model_name='feedbackresponse',
index=models.Index(fields=['feedback', '-created_at'], name='feedback_fe_feedbac_bc9e33_idx'),
),
]

View File

@ -108,13 +108,13 @@ 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

View File

@ -11,7 +11,7 @@ from django.views.decorators.http import require_http_methods
from apps.accounts.models import User
from apps.core.services import AuditService
from apps.organizations.models import Department, Hospital, Patient, Physician
from apps.organizations.models import Department, Hospital, Patient, Staff
from .models import (
Feedback,
@ -42,7 +42,7 @@ 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)
@ -88,9 +88,9 @@ def feedback_list(request):
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:
@ -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',

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:16
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid

View File

@ -36,7 +36,7 @@ def process_inbound_event(self, event_id):
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
@ -71,18 +71,18 @@ def process_inbound_event(self, event_id):
# 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:
@ -97,7 +97,7 @@ def process_inbound_event(self, event_id):
with transaction.atomic():
success = stage_instance.complete(
event=event,
physician=physician,
staff=staff,
department=department,
metadata=event.payload_json
)

View File

@ -83,7 +83,7 @@ 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']
@ -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 = [
@ -154,7 +154,7 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
'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')
@ -172,7 +172,7 @@ class PatientJourneyStageInstanceAdmin(admin.ModelAdmin):
return qs.select_related(
'journey_instance',
'stage_template',
'physician',
'staff',
'department',
'survey_instance',
'completed_by_event'

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:16
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid
@ -87,7 +87,7 @@ class Migration(migrations.Migration):
('completed_by_event', models.ForeignKey(blank=True, help_text='Integration event that completed this stage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_stages', to='integrations.inboundevent')),
('department', models.ForeignKey(blank=True, help_text='Department where this stage occurred', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='organizations.department')),
('journey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stage_instances', to='journeys.patientjourneyinstance')),
('physician', models.ForeignKey(blank=True, help_text='Physician associated with this stage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='organizations.physician')),
('staff', models.ForeignKey(blank=True, help_text='Staff member associated with this stage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='organizations.staff')),
],
options={
'ordering': ['journey_instance', 'stage_template__order'],

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:16
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
from django.db import migrations, models

View File

@ -294,13 +294,13 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
)
# Context from event
physician = models.ForeignKey(
'organizations.Physician',
staff = models.ForeignKey(
'organizations.Staff',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='journey_stages',
help_text="Physician associated with this stage"
help_text="Staff member associated with this stage"
)
department = models.ForeignKey(
'organizations.Department',
@ -344,15 +344,15 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
"""Check if this stage can be completed"""
return self.status in [StageStatus.PENDING, StageStatus.IN_PROGRESS]
def complete(self, event=None, physician=None, department=None, metadata=None):
def complete(self, event=None, staff=None, department=None, metadata=None):
"""
Mark stage as completed.
This method should be called by the event processing task.
This method should be called by event processing task.
It will:
1. Update status to COMPLETED
2. Set completion timestamp
3. Attach event, physician, department
3. Attach event, staff, department
4. Trigger survey creation if configured
"""
from django.utils import timezone
@ -364,8 +364,8 @@ class PatientJourneyStageInstance(UUIDModel, TimeStampedModel):
self.completed_at = timezone.now()
self.completed_by_event = event
if physician:
self.physician = physician
if staff:
self.staff = staff
if department:
self.department = department
if metadata:

View File

@ -35,7 +35,7 @@ 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']
@ -102,7 +102,8 @@ 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']

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:38
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid

View File

@ -182,6 +182,83 @@ class NotificationService:
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'):
"""

View File

@ -3,7 +3,25 @@ Organizations admin
"""
from django.contrib import admin
from .models import Department, Employee, Hospital, Patient, Physician
from .models import Department, Hospital, Organization, Patient, Staff
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
"""Organization admin"""
list_display = ['name', 'code', 'city', 'status', 'created_at']
list_filter = ['status', 'city']
search_fields = ['name', 'name_ar', 'code', 'license_number']
ordering = ['name']
fieldsets = (
(None, {'fields': ('name', 'name_ar', 'code')}),
('Contact Information', {'fields': ('address', 'city', 'phone', 'email', 'website')}),
('Details', {'fields': ('license_number', 'status', 'logo')}),
('Metadata', {'fields': ('created_at', 'updated_at')}),
)
readonly_fields = ['created_at', 'updated_at']
@admin.register(Hospital)
@ -15,11 +33,12 @@ class HospitalAdmin(admin.ModelAdmin):
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']
@ -48,21 +67,21 @@ class DepartmentAdmin(admin.ModelAdmin):
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')}),
)
@ -74,29 +93,6 @@ class PhysicianAdmin(admin.ModelAdmin):
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"""

View File

@ -0,0 +1,127 @@
"""
Management command to create a default organization and assign orphaned hospitals
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.organizations.models import Organization, Hospital
class Command(BaseCommand):
help = 'Create a default organization and assign hospitals without organization'
def add_arguments(self, parser):
parser.add_argument(
'--name',
type=str,
default='Default Healthcare Organization',
help='Name of the default organization to create'
)
parser.add_argument(
'--code',
type=str,
default='DEFAULT',
help='Code of the default organization'
)
parser.add_argument(
'--force',
action='store_true',
help='Force reassignment even if hospital already has an organization'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without making changes'
)
def handle(self, *args, **options):
name = options['name']
code = options['code']
force = options['force']
dry_run = options['dry_run']
self.stdout.write(f"\n{'='*60}")
self.stdout.write(f"Organization Assignment Script")
self.stdout.write(f"{'='*60}\n")
with transaction.atomic():
# Get or create default organization
org, created = Organization.objects.get_or_create(
code=code,
defaults={
'name': name,
'name_ar': name, # Use same name for Arabic
'status': 'active'
}
)
if created:
self.stdout.write(
self.style.SUCCESS(f"✓ Created organization: {org.name} ({org.code})")
)
else:
self.stdout.write(
self.style.SUCCESS(f"✓ Found existing organization: {org.name} ({org.code})")
)
# Find hospitals without organization
if force:
hospitals_to_assign = Hospital.objects.all()
self.stdout.write(
self.style.WARNING(f"\nForce mode: Will assign ALL hospitals")
)
else:
hospitals_to_assign = Hospital.objects.filter(organization__isnull=True)
count = hospitals_to_assign.count()
self.stdout.write(
self.style.SUCCESS(f"\nFound {count} hospitals without organization")
)
if not hospitals_to_assign.exists():
self.stdout.write(
self.style.SUCCESS("\n✓ All hospitals already have organizations assigned")
)
return
# Display hospitals to be assigned
self.stdout.write("\nHospitals to assign:")
for i, hospital in enumerate(hospitals_to_assign, 1):
org_name = hospital.organization.name if hospital.organization else "None"
self.stdout.write(
f" {i}. {hospital.name} (Code: {hospital.code}) - Current: {org_name}"
)
if dry_run:
self.stdout.write("\n" + "="*60)
self.stdout.write(
self.style.WARNING("DRY RUN: No changes were made")
)
self.stdout.write("="*60 + "\n")
return
# Confirm assignment
if not force:
confirm = input(f"\nAssign {hospitals_to_assign.count()} hospital(s) to '{org.name}'? (yes/no): ")
if confirm.lower() not in ['yes', 'y']:
self.stdout.write(self.style.ERROR("\n✓ Operation cancelled"))
return
# Assign hospitals to organization
count = hospitals_to_assign.update(organization=org)
self.stdout.write(
self.style.SUCCESS(f"\n✓ Successfully assigned {count} hospital(s) to '{org.name}'")
)
# Summary
self.stdout.write("\n" + "="*60)
self.stdout.write("Summary:")
self.stdout.write(f" Organization: {org.name} ({org.code})")
self.stdout.write(f" Hospitals assigned: {count}")
self.stdout.write(f" Total hospitals in organization: {org.hospitals.count()}")
self.stdout.write("="*60 + "\n")
if dry_run:
self.stdout.write(self.style.WARNING("\nDry run completed - no changes applied\n"))
else:
self.stdout.write(self.style.SUCCESS("\nOrganization assignment completed successfully!\n"))

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 10:07
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid
@ -31,12 +31,37 @@ class Migration(migrations.Migration):
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('license_number', models.CharField(blank=True, max_length=100)),
('capacity', models.IntegerField(blank=True, help_text='Bed capacity', null=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Hospital configuration settings')),
],
options={
'verbose_name_plural': 'Hospitals',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Organization',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('code', models.CharField(db_index=True, max_length=50, unique=True)),
('phone', models.CharField(blank=True, max_length=20)),
('email', models.EmailField(blank=True, max_length=254)),
('address', models.TextField(blank=True)),
('city', models.CharField(blank=True, max_length=100)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('logo', models.ImageField(blank=True, null=True, upload_to='organizations/logos/')),
('website', models.URLField(blank=True)),
('license_number', models.CharField(blank=True, max_length=100)),
],
options={
'verbose_name': 'Organization',
'verbose_name_plural': 'Organizations',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Department',
fields=[
@ -59,23 +84,10 @@ class Migration(migrations.Migration):
'unique_together': {('hospital', 'code')},
},
),
migrations.CreateModel(
name='Employee',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('employee_id', models.CharField(db_index=True, max_length=50, unique=True)),
('job_title', models.CharField(max_length=200)),
('hire_date', models.DateField(blank=True, null=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employees', to='organizations.department')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='employee_profile', to=settings.AUTH_USER_MODEL)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employees', to='organizations.hospital')),
],
options={
'ordering': ['user__last_name', 'user__first_name'],
},
migrations.AddField(
model_name='hospital',
name='organization',
field=models.ForeignKey(blank=True, help_text='Parent organization (null for backward compatibility)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hospitals', to='organizations.organization'),
),
migrations.CreateModel(
name='Patient',
@ -103,7 +115,7 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='Physician',
name='Staff',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
@ -112,17 +124,18 @@ class Migration(migrations.Migration):
('last_name', models.CharField(max_length=100)),
('first_name_ar', models.CharField(blank=True, max_length=100)),
('last_name_ar', models.CharField(blank=True, max_length=100)),
('license_number', models.CharField(db_index=True, max_length=100, unique=True)),
('specialization', models.CharField(max_length=200)),
('phone', models.CharField(blank=True, max_length=20)),
('email', models.EmailField(blank=True, max_length=254)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='physicians', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='physicians', to='organizations.hospital')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='physician_profile', to=settings.AUTH_USER_MODEL)),
('staff_type', models.CharField(choices=[('physician', 'Physician'), ('nurse', 'Nurse'), ('admin', 'Administrative'), ('other', 'Other')], max_length=20)),
('job_title', models.CharField(max_length=200)),
('license_number', models.CharField(blank=True, max_length=100, null=True, unique=True)),
('specialization', models.CharField(blank=True, max_length=200)),
('employee_id', models.CharField(db_index=True, max_length=50, unique=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='active', max_length=20)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff', to='organizations.hospital')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['last_name', 'first_name'],
'abstract': False,
},
),
]

View File

@ -6,8 +6,50 @@ from django.db import models
from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices
class Organization(UUIDModel, TimeStampedModel):
"""Top-level healthcare organization/company"""
name = models.CharField(max_length=200)
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
code = models.CharField(max_length=50, unique=True, db_index=True)
# Contact information
phone = models.CharField(max_length=20, blank=True)
email = models.EmailField(blank=True)
address = models.TextField(blank=True)
city = models.CharField(max_length=100, blank=True)
# Status
status = models.CharField(
max_length=20,
choices=StatusChoices.choices,
default=StatusChoices.ACTIVE,
db_index=True
)
# Branding and metadata
logo = models.ImageField(upload_to='organizations/logos/', null=True, blank=True)
website = models.URLField(blank=True)
license_number = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['name']
verbose_name = 'Organization'
verbose_name_plural = 'Organizations'
def __str__(self):
return self.name
class Hospital(UUIDModel, TimeStampedModel):
"""Hospital/Facility model"""
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='hospitals',
help_text="Parent organization (null for backward compatibility)"
)
name = models.CharField(max_length=200)
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
code = models.CharField(max_length=50, unique=True, db_index=True)
@ -29,6 +71,7 @@ class Hospital(UUIDModel, TimeStampedModel):
# Metadata
license_number = models.CharField(max_length=100, blank=True)
capacity = models.IntegerField(null=True, blank=True, help_text="Bed capacity")
metadata = models.JSONField(default=dict, blank=True, help_text="Hospital configuration settings")
metadata = models.JSONField(default=dict, blank=True)
@ -38,8 +81,11 @@ class Hospital(UUIDModel, TimeStampedModel):
def __str__(self):
return self.name
<<<<<<< HEAD
# TODO: Add branch
=======
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
class Department(UUIDModel, TimeStampedModel):
"""Department within a hospital"""
@ -87,96 +133,137 @@ class Department(UUIDModel, TimeStampedModel):
def __str__(self):
return f"{self.hospital.name} - {self.name}"
# TODO Add Section
class Physician(UUIDModel, TimeStampedModel):
"""Physician/Doctor model"""
# Link to user account (optional - some physicians may not have system access)
class Staff(UUIDModel, TimeStampedModel):
class StaffType(models.TextChoices):
PHYSICIAN = 'physician', 'Physician'
NURSE = 'nurse', 'Nurse'
ADMIN = 'admin', 'Administrative'
OTHER = 'other', 'Other'
# Link to User (Keep it optional for external/temp staff)
user = models.OneToOneField(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='physician_profile'
null=True, blank=True,
related_name='staff_profile'
)
# Basic information
# Unified Identity (AI will search these 4 fields)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
first_name_ar = models.CharField(max_length=100, blank=True)
last_name_ar = models.CharField(max_length=100, blank=True)
# Professional information
license_number = models.CharField(max_length=100, unique=True, db_index=True)
specialization = models.CharField(max_length=200)
# Role Logic
staff_type = models.CharField(max_length=20, choices=StaffType.choices)
job_title = models.CharField(max_length=200) # "Cardiologist", "Senior Nurse", etc.
# Organization
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='physicians')
department = models.ForeignKey(
Department,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='physicians'
)
# Contact
phone = models.CharField(max_length=20, blank=True)
email = models.EmailField(blank=True)
# Status
status = models.CharField(
max_length=20,
choices=StatusChoices.choices,
default=StatusChoices.ACTIVE,
db_index=True
)
class Meta:
ordering = ['last_name', 'first_name']
def __str__(self):
return f"Dr. {self.first_name} {self.last_name}"
def get_full_name(self):
return f"{self.first_name} {self.last_name}"
class Employee(UUIDModel, TimeStampedModel):
"""Employee model (non-physician staff)"""
user = models.OneToOneField(
'accounts.User',
on_delete=models.CASCADE,
related_name='employee_profile'
)
# Organization
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='employees')
department = models.ForeignKey(
Department,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='employees'
)
# Job information
# Professional Data (Nullable for non-physicians)
license_number = models.CharField(max_length=100, unique=True, null=True, blank=True)
specialization = models.CharField(max_length=200, blank=True)
employee_id = models.CharField(max_length=50, unique=True, db_index=True)
job_title = models.CharField(max_length=200)
hire_date = models.DateField(null=True, blank=True)
# Status
status = models.CharField(
max_length=20,
choices=StatusChoices.choices,
default=StatusChoices.ACTIVE,
db_index=True
)
# Organization
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='staff')
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True)
class Meta:
ordering = ['user__last_name', 'user__first_name']
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE)
def __str__(self):
return f"{self.user.get_full_name()} - {self.job_title}"
prefix = "Dr. " if self.staff_type == self.StaffType.PHYSICIAN else ""
return f"{prefix}{self.first_name} {self.last_name}"
# TODO Add Section
# class Physician(UUIDModel, TimeStampedModel):
# """Physician/Doctor model"""
# # Link to user account (optional - some physicians may not have system access)
# user = models.OneToOneField(
# 'accounts.User',
# on_delete=models.SET_NULL,
# null=True,
# blank=True,
# related_name='physician_profile'
# )
# # Basic information
# first_name = models.CharField(max_length=100)
# last_name = models.CharField(max_length=100)
# first_name_ar = models.CharField(max_length=100, blank=True)
# last_name_ar = models.CharField(max_length=100, blank=True)
# # Professional information
# license_number = models.CharField(max_length=100, unique=True, db_index=True)
# specialization = models.CharField(max_length=200)
# # Organization
# hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='physicians')
# department = models.ForeignKey(
# Department,
# on_delete=models.SET_NULL,
# null=True,
# blank=True,
# related_name='physicians'
# )
# # Contact
# phone = models.CharField(max_length=20, blank=True)
# email = models.EmailField(blank=True)
# # Status
# status = models.CharField(
# max_length=20,
# choices=StatusChoices.choices,
# default=StatusChoices.ACTIVE,
# db_index=True
# )
# class Meta:
# ordering = ['last_name', 'first_name']
# def __str__(self):
# return f"Dr. {self.first_name} {self.last_name}"
# def get_full_name(self):
# return f"{self.first_name} {self.last_name}"
# class Employee(UUIDModel, TimeStampedModel):
# """Employee model (non-physician staff)"""
# user = models.OneToOneField(
# 'accounts.User',
# on_delete=models.CASCADE,
# related_name='employee_profile'
# )
# # Organization
# hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='employees')
# department = models.ForeignKey(
# Department,
# on_delete=models.SET_NULL,
# null=True,
# blank=True,
# related_name='employees'
# )
# # Job information
# employee_id = models.CharField(max_length=50, unique=True, db_index=True)
# job_title = models.CharField(max_length=200)
# hire_date = models.DateField(null=True, blank=True)
# # Status
# status = models.CharField(
# max_length=20,
# choices=StatusChoices.choices,
# default=StatusChoices.ACTIVE,
# db_index=True
# )
# class Meta:
# ordering = ['user__last_name', 'user__first_name']
# def __str__(self):
# return f"{self.user.get_full_name()} - {self.job_title}"
class Patient(UUIDModel, TimeStampedModel):
@ -229,3 +316,27 @@ class Patient(UUIDModel, TimeStampedModel):
def get_full_name(self):
return f"{self.first_name} {self.last_name}"
@staticmethod
def generate_mrn():
"""
Generate a unique Medical Record Number (MRN).
Returns:
str: A unique MRN in the format: PTN-YYYYMMDD-XXXXXX
where XXXXXX is a random 6-digit number
"""
import random
from datetime import datetime
# Generate MRN with date prefix for better traceability
date_prefix = datetime.now().strftime('%Y%m%d')
random_suffix = random.randint(100000, 999999)
mrn = f"PTN-{date_prefix}-{random_suffix}"
# Ensure uniqueness (in case of collision)
while Patient.objects.filter(mrn=mrn).exists():
random_suffix = random.randint(100000, 999999)
mrn = f"PTN-{date_prefix}-{random_suffix}"
return mrn

View File

@ -3,21 +3,46 @@ 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"""
@ -42,46 +67,26 @@ 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)

View File

@ -1,12 +1,9 @@
"""
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
@ -104,9 +101,9 @@ def department_list(request):
@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
@ -126,14 +123,20 @@ def physician_list(request):
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
@ -152,12 +155,128 @@ def physician_list(request):
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

View File

@ -1,23 +1,32 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import DepartmentViewSet, EmployeeViewSet, HospitalViewSet, PatientViewSet, PhysicianViewSet
from .views import (
DepartmentViewSet,
HospitalViewSet,
OrganizationViewSet,
PatientViewSet,
StaffViewSet,
)
from . import ui_views
app_name = 'organizations'
router = DefaultRouter()
router.register(r'api/organizations', OrganizationViewSet, basename='organization-api')
router.register(r'api/hospitals', HospitalViewSet, basename='hospital-api')
router.register(r'api/departments', DepartmentViewSet, basename='department-api')
router.register(r'api/physicians', PhysicianViewSet, basename='physician-api')
router.register(r'api/employees', EmployeeViewSet, basename='employee-api')
router.register(r'api/staff', StaffViewSet, basename='staff-api')
router.register(r'api/patients', PatientViewSet, basename='patient-api')
urlpatterns = [
# UI Views
path('organizations/', ui_views.organization_list, name='organization_list'),
path('organizations/create/', ui_views.organization_create, name='organization_create'),
path('organizations/<uuid:pk>/', ui_views.organization_detail, name='organization_detail'),
path('hospitals/', ui_views.hospital_list, name='hospital_list'),
path('departments/', ui_views.department_list, name='department_list'),
path('physicians/', ui_views.physician_list, name='physician_list'),
path('staff/', ui_views.staff_list, name='staff_list'),
path('patients/', ui_views.patient_list, name='patient_list'),
# API Routes

View File

@ -1,22 +1,60 @@
"""
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.
@ -28,7 +66,7 @@ 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']
@ -68,7 +106,7 @@ 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']
@ -101,80 +139,40 @@ class DepartmentViewSet(viewsets.ModelViewSet):
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
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
# Others see staff in their hospital
if user.hospital:
return queryset.filter(hospital=user.hospital)
@ -191,7 +189,7 @@ class PatientViewSet(viewsets.ModelViewSet):
"""
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']

View File

@ -10,18 +10,18 @@ from .models import PhysicianMonthlyRating
class PhysicianMonthlyRatingAdmin(admin.ModelAdmin):
"""Physician monthly rating admin"""
list_display = [
'physician', 'year', 'month', 'average_rating',
'staff', 'year', 'month', 'average_rating',
'total_surveys', 'hospital_rank', 'department_rank'
]
list_filter = ['year', 'month', 'physician__hospital', 'physician__department']
list_filter = ['year', 'month', 'staff__hospital', 'staff__department']
search_fields = [
'physician__first_name', 'physician__last_name', 'physician__license_number'
'staff__first_name', 'staff__last_name', 'staff__license_number'
]
ordering = ['-year', '-month', '-average_rating']
fieldsets = (
('Physician & Period', {
'fields': ('physician', 'year', 'month')
'fields': ('staff', 'year', 'month')
}),
('Ratings', {
'fields': (
@ -45,4 +45,4 @@ class PhysicianMonthlyRatingAdmin(admin.ModelAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('physician', 'physician__hospital', 'physician__department')
return qs.select_related('staff', 'staff__hospital', 'staff__department')

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 11:25
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid
@ -31,12 +31,12 @@ class Migration(migrations.Migration):
('hospital_rank', models.IntegerField(blank=True, help_text='Rank within hospital', null=True)),
('department_rank', models.IntegerField(blank=True, help_text='Rank within department', null=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('physician', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_ratings', to='organizations.physician')),
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_ratings', to='organizations.staff')),
],
options={
'ordering': ['-year', '-month', '-average_rating'],
'indexes': [models.Index(fields=['physician', '-year', '-month'], name='physicians__physici_963ee5_idx'), models.Index(fields=['year', 'month', '-average_rating'], name='physicians__year_e38883_idx')],
'unique_together': {('physician', 'year', 'month')},
'indexes': [models.Index(fields=['staff', '-year', '-month'], name='physicians__staff_i_f4cc8b_idx'), models.Index(fields=['year', 'month', '-average_rating'], name='physicians__year_e38883_idx')],
'unique_together': {('staff', 'year', 'month')},
},
),
]

View File

@ -17,8 +17,8 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
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'
)
@ -65,11 +65,11 @@ class PhysicianMonthlyRating(UUIDModel, TimeStampedModel):
class Meta:
ordering = ['-year', '-month', '-average_rating']
unique_together = [['physician', 'year', 'month']]
unique_together = [['staff', 'year', 'month']]
indexes = [
models.Index(fields=['physician', '-year', '-month']),
models.Index(fields=['staff', '-year', '-month']),
models.Index(fields=['year', 'month', '-average_rating']),
]
def __str__(self):
return f"{self.physician.get_full_name()} - {self.year}-{self.month:02d}: {self.average_rating}"
return f"{self.staff.get_full_name()} - {self.year}-{self.month:02d}: {self.average_rating}"

View File

@ -3,7 +3,7 @@ Physicians serializers
"""
from rest_framework import serializers
from apps.organizations.models import Physician
from apps.organizations.models import Staff
from .models import PhysicianMonthlyRating
@ -15,7 +15,7 @@ class PhysicianSerializer(serializers.ModelSerializer):
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',

View File

@ -32,7 +32,7 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
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
@ -45,7 +45,7 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
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
@ -119,7 +119,7 @@ def calculate_monthly_physician_ratings(self, year=None, month=None):
# Create or update rating
rating, created = PhysicianMonthlyRating.objects.update_or_create(
physician=physician,
staff=physician,
year=year,
month=month,
defaults={
@ -199,7 +199,7 @@ def update_physician_rankings(year, month):
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')
@ -216,7 +216,7 @@ def update_physician_rankings(year, month):
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')
@ -260,15 +260,15 @@ def generate_physician_performance_report(physician_id, year, 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()
@ -284,14 +284,14 @@ def generate_physician_performance_report(physician_id, year, month):
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
)
@ -338,7 +338,7 @@ def generate_physician_performance_report(physician_id, year, month):
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}

View File

@ -7,7 +7,7 @@ from django.db.models import Avg, Count, Q
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from apps.organizations.models import Department, Hospital, Physician
from apps.organizations.models import Department, Hospital, Staff
from .models import PhysicianMonthlyRating
@ -24,7 +24,7 @@ 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
@ -76,13 +76,13 @@ def physician_list(request):
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:
@ -98,7 +98,7 @@ def physician_list(request):
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 = {
@ -134,7 +134,7 @@ 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
)
@ -151,7 +151,7 @@ def physician_detail(request, pk):
# Get current month rating
current_month_rating = PhysicianMonthlyRating.objects.filter(
physician=physician,
staff=physician,
year=current_year,
month=current_month
).first()
@ -160,14 +160,14 @@ def physician_detail(request, pk):
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
)
@ -178,11 +178,11 @@ def physician_detail(request, pk):
# 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()
@ -240,19 +240,19 @@ def leaderboard(request):
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]
@ -266,7 +266,7 @@ def leaderboard(request):
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()
@ -284,7 +284,7 @@ def leaderboard(request):
leaderboard.append({
'rank': rank,
'rating': rating,
'physician': rating.physician,
'physician': rating.staff,
'trend': trend,
'trend_value': trend_value,
'prev_rating': prev_rating
@ -302,7 +302,7 @@ def leaderboard(request):
# 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'),
@ -348,26 +348,26 @@ 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:
@ -381,9 +381,9 @@ def ratings_list(request):
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
@ -441,23 +441,23 @@ def specialization_overview(request):
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,
@ -533,23 +533,23 @@ def department_overview(request):
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

View File

@ -8,7 +8,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.accounts.permissions import IsPXAdminOrHospitalAdmin
from apps.organizations.models import Physician
from apps.organizations.models import Staff
from .models import PhysicianMonthlyRating
from .serializers import (
@ -27,7 +27,7 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
- 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']
@ -73,7 +73,7 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
# Get current month rating
current_month_rating = PhysicianMonthlyRating.objects.filter(
physician=physician,
staff=physician,
year=current_year,
month=current_month
).first()
@ -82,14 +82,14 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
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
)
@ -100,7 +100,7 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
# 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()
@ -142,7 +142,7 @@ class PhysicianViewSet(viewsets.ReadOnlyModelViewSet):
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)
@ -160,17 +160,17 @@ 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
@ -180,7 +180,7 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
# 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()
@ -206,19 +206,19 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
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]
@ -232,7 +232,7 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
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()
@ -245,11 +245,11 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
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,
@ -284,11 +284,11 @@ class PhysicianMonthlyRatingViewSet(viewsets.ReadOnlyModelViewSet):
# 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(

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 11:25
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid
@ -12,11 +12,27 @@ class Migration(migrations.Migration):
dependencies = [
('organizations', '0001_initial'),
('px_action_center', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='QIProjectTask',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=500)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('due_date', models.DateField(blank=True, null=True)),
('completed_date', models.DateField(blank=True, null=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['project', 'order'],
},
),
migrations.CreateModel(
name='QIProject',
fields=[
@ -36,34 +52,9 @@ class Migration(migrations.Migration):
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_projects', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='qi_projects', to='organizations.hospital')),
('project_lead', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_projects', to=settings.AUTH_USER_MODEL)),
('related_actions', models.ManyToManyField(blank=True, related_name='qi_projects', to='px_action_center.pxaction')),
('team_members', models.ManyToManyField(blank=True, related_name='qi_projects', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='QIProjectTask',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=500)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('due_date', models.DateField(blank=True, null=True)),
('completed_date', models.DateField(blank=True, null=True)),
('order', models.IntegerField(default=0)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_tasks', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.qiproject')),
],
options={
'ordering': ['project', 'order'],
},
),
migrations.AddIndex(
model_name='qiproject',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='projects_qi_hospita_e5dfc7_idx'),
),
]

View File

@ -0,0 +1,43 @@
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('projects', '0001_initial'),
('px_action_center', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='qiproject',
name='related_actions',
field=models.ManyToManyField(blank=True, related_name='qi_projects', to='px_action_center.pxaction'),
),
migrations.AddField(
model_name='qiproject',
name='team_members',
field=models.ManyToManyField(blank=True, related_name='qi_projects', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='qiprojecttask',
name='assigned_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_tasks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='qiprojecttask',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.qiproject'),
),
migrations.AddIndex(
model_name='qiproject',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='projects_qi_hospita_e5dfc7_idx'),
),
]

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 11:11
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.14 on 2025-12-14 11:19
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid

View File

@ -1,7 +1,8 @@
# Generated by Django 5.0.14 on 2025-12-14 10:16
# Generated by Django 5.0.14 on 2026-01-05 10:43
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
@ -10,7 +11,9 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('journeys', '0001_initial'),
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@ -21,13 +24,40 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')),
('survey_type', models.CharField(choices=[('stage', 'Journey Stage Survey'), ('complaint_resolution', 'Complaint Resolution Satisfaction'), ('general', 'General Feedback'), ('nps', 'Net Promoter Score')], db_index=True, default='stage', max_length=50)),
('scoring_method', models.CharField(choices=[('average', 'Average Score'), ('weighted', 'Weighted Average'), ('nps', 'NPS Calculation')], default='average', max_length=20)),
('negative_threshold', models.DecimalField(decimal_places=1, default=3.0, help_text='Scores below this trigger PX actions (out of 5)', max_digits=3)),
('is_active', models.BooleanField(db_index=True, default=True)),
('version', models.IntegerField(default=1)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='survey_templates', to='organizations.hospital')),
],
options={
'ordering': ['name'],
'ordering': ['hospital', 'name'],
},
),
migrations.CreateModel(
name='SurveyQuestion',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('text', models.TextField(verbose_name='Question Text (English)')),
('text_ar', models.TextField(blank=True, verbose_name='Question Text (Arabic)')),
('question_type', models.CharField(choices=[('rating', 'Rating (1-5 stars)'), ('nps', 'NPS (0-10)'), ('yes_no', 'Yes/No'), ('multiple_choice', 'Multiple Choice'), ('text', 'Text (Short Answer)'), ('textarea', 'Text Area (Long Answer)'), ('likert', 'Likert Scale (1-5)')], default='rating', max_length=20)),
('order', models.IntegerField(default=0, help_text='Display order')),
('is_required', models.BooleanField(default=True)),
('choices_json', models.JSONField(blank=True, default=list, help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]")),
('weight', models.DecimalField(decimal_places=2, default=1.0, help_text='Weight for weighted average scoring', max_digits=3)),
('branch_logic', models.JSONField(blank=True, default=dict, help_text="Conditional display logic: {'show_if': {'question_id': 'value'}}")),
('help_text', models.TextField(blank=True)),
('help_text_ar', models.TextField(blank=True)),
('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='surveys.surveytemplate')),
],
options={
'ordering': ['survey_template', 'order'],
},
),
migrations.CreateModel(
@ -37,14 +67,71 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('encounter_id', models.CharField(blank=True, db_index=True, max_length=100)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('delivery_channel', models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email')], default='sms', max_length=20)),
('recipient_phone', models.CharField(blank=True, max_length=20)),
('recipient_email', models.EmailField(blank=True, max_length=254)),
('access_token', models.CharField(blank=True, db_index=True, help_text='Secure token for survey access', max_length=100, unique=True)),
('token_expires_at', models.DateTimeField(blank=True, help_text='Token expiration date', null=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20)),
('sent_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('opened_at', models.DateTimeField(blank=True, null=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('total_score', models.DecimalField(blank=True, decimal_places=2, help_text='Calculated total score', max_digits=5, null=True)),
('is_negative', models.BooleanField(db_index=True, default=False, help_text='True if score below threshold')),
('metadata', models.JSONField(blank=True, default=dict)),
('patient_contacted', models.BooleanField(default=False, help_text='Whether patient was contacted about negative survey')),
('patient_contacted_at', models.DateTimeField(blank=True, null=True)),
('contact_notes', models.TextField(blank=True, help_text='Notes from patient contact')),
('issue_resolved', models.BooleanField(default=False, help_text='Whether the issue was resolved/explained')),
('satisfaction_feedback_sent', models.BooleanField(default=False, help_text='Whether satisfaction feedback form was sent')),
('satisfaction_feedback_sent_at', models.DateTimeField(blank=True, null=True)),
('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')),
('journey_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneyinstance')),
('journey_stage_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneystageinstance')),
('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='organizations.patient')),
('patient_contacted_by', models.ForeignKey(blank=True, help_text='User who contacted the patient', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacted_surveys', to=settings.AUTH_USER_MODEL)),
('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='surveys.surveytemplate')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SurveyResponse',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('numeric_value', models.DecimalField(blank=True, decimal_places=2, help_text='For rating, NPS, Likert questions', max_digits=10, null=True)),
('text_value', models.TextField(blank=True, help_text='For text, textarea questions')),
('choice_value', models.CharField(blank=True, help_text='For multiple choice questions', max_length=200)),
('response_time_seconds', models.IntegerField(blank=True, help_text='Time taken to answer this question', null=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='responses', to='surveys.surveyquestion')),
('survey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='surveys.surveyinstance')),
],
options={
'ordering': ['survey_instance', 'question__order'],
'unique_together': {('survey_instance', 'question')},
},
),
migrations.AddIndex(
model_name='surveytemplate',
index=models.Index(fields=['hospital', 'survey_type', 'is_active'], name='surveys_sur_hospita_0c8e30_idx'),
),
migrations.AddIndex(
model_name='surveyquestion',
index=models.Index(fields=['survey_template', 'order'], name='surveys_sur_survey__d8acd5_idx'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['patient', '-created_at'], name='surveys_sur_patient_7e68b1_idx'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['status', '-sent_at'], name='surveys_sur_status_ce377b_idx'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['is_negative', '-completed_at'], name='surveys_sur_is_nega_46c933_idx'),
),
]

View File

@ -1,196 +0,0 @@
# Generated by Django 5.0.14 on 2025-12-14 10:34
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('journeys', '0002_initial'),
('organizations', '0001_initial'),
('surveys', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SurveyQuestion',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('text', models.TextField(verbose_name='Question Text (English)')),
('text_ar', models.TextField(blank=True, verbose_name='Question Text (Arabic)')),
('question_type', models.CharField(choices=[('rating', 'Rating (1-5 stars)'), ('nps', 'NPS (0-10)'), ('yes_no', 'Yes/No'), ('multiple_choice', 'Multiple Choice'), ('text', 'Text (Short Answer)'), ('textarea', 'Text Area (Long Answer)'), ('likert', 'Likert Scale (1-5)')], default='rating', max_length=20)),
('order', models.IntegerField(default=0, help_text='Display order')),
('is_required', models.BooleanField(default=True)),
('choices_json', models.JSONField(blank=True, default=list, help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]")),
('weight', models.DecimalField(decimal_places=2, default=1.0, help_text='Weight for weighted average scoring', max_digits=3)),
('branch_logic', models.JSONField(blank=True, default=dict, help_text="Conditional display logic: {'show_if': {'question_id': 'value'}}")),
('help_text', models.TextField(blank=True)),
('help_text_ar', models.TextField(blank=True)),
],
options={
'ordering': ['survey_template', 'order'],
},
),
migrations.CreateModel(
name='SurveyResponse',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('numeric_value', models.DecimalField(blank=True, decimal_places=2, help_text='For rating, NPS, Likert questions', max_digits=10, null=True)),
('text_value', models.TextField(blank=True, help_text='For text, textarea questions')),
('choice_value', models.CharField(blank=True, help_text='For multiple choice questions', max_length=200)),
('response_time_seconds', models.IntegerField(blank=True, help_text='Time taken to answer this question', null=True)),
],
options={
'ordering': ['survey_instance', 'question__order'],
},
),
migrations.AlterModelOptions(
name='surveytemplate',
options={'ordering': ['hospital', 'name']},
),
migrations.AddField(
model_name='surveyinstance',
name='access_token',
field=models.CharField(blank=True, db_index=True, help_text='Secure token for survey access', max_length=100, unique=True),
),
migrations.AddField(
model_name='surveyinstance',
name='delivery_channel',
field=models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email')], default='sms', max_length=20),
),
migrations.AddField(
model_name='surveyinstance',
name='is_negative',
field=models.BooleanField(db_index=True, default=False, help_text='True if score below threshold'),
),
migrations.AddField(
model_name='surveyinstance',
name='journey_instance',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneyinstance'),
),
migrations.AddField(
model_name='surveyinstance',
name='journey_stage_instance',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneystageinstance'),
),
migrations.AddField(
model_name='surveyinstance',
name='metadata',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='surveyinstance',
name='opened_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='surveyinstance',
name='recipient_email',
field=models.EmailField(blank=True, max_length=254),
),
migrations.AddField(
model_name='surveyinstance',
name='recipient_phone',
field=models.CharField(blank=True, max_length=20),
),
migrations.AddField(
model_name='surveyinstance',
name='token_expires_at',
field=models.DateTimeField(blank=True, help_text='Token expiration date', null=True),
),
migrations.AddField(
model_name='surveyinstance',
name='total_score',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Calculated total score', max_digits=5, null=True),
),
migrations.AddField(
model_name='surveytemplate',
name='description_ar',
field=models.TextField(blank=True, verbose_name='Description (Arabic)'),
),
migrations.AddField(
model_name='surveytemplate',
name='negative_threshold',
field=models.DecimalField(decimal_places=1, default=3.0, help_text='Scores below this trigger PX actions (out of 5)', max_digits=3),
),
migrations.AddField(
model_name='surveytemplate',
name='scoring_method',
field=models.CharField(choices=[('average', 'Average Score'), ('weighted', 'Weighted Average'), ('nps', 'NPS Calculation')], default='average', max_length=20),
),
migrations.AddField(
model_name='surveytemplate',
name='survey_type',
field=models.CharField(choices=[('stage', 'Journey Stage Survey'), ('complaint_resolution', 'Complaint Resolution Satisfaction'), ('general', 'General Feedback'), ('nps', 'Net Promoter Score')], db_index=True, default='stage', max_length=50),
),
migrations.AddField(
model_name='surveytemplate',
name='version',
field=models.IntegerField(default=1),
),
migrations.AlterField(
model_name='surveyinstance',
name='sent_at',
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
migrations.AlterField(
model_name='surveyinstance',
name='status',
field=models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20),
),
migrations.AlterField(
model_name='surveytemplate',
name='is_active',
field=models.BooleanField(db_index=True, default=True),
),
migrations.AlterField(
model_name='surveytemplate',
name='name_ar',
field=models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['patient', '-created_at'], name='surveys_sur_patient_7e68b1_idx'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['status', '-sent_at'], name='surveys_sur_status_ce377b_idx'),
),
migrations.AddIndex(
model_name='surveyinstance',
index=models.Index(fields=['is_negative', '-completed_at'], name='surveys_sur_is_nega_46c933_idx'),
),
migrations.AddIndex(
model_name='surveytemplate',
index=models.Index(fields=['hospital', 'survey_type', 'is_active'], name='surveys_sur_hospita_0c8e30_idx'),
),
migrations.AddField(
model_name='surveyquestion',
name='survey_template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='surveys.surveytemplate'),
),
migrations.AddField(
model_name='surveyresponse',
name='question',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='responses', to='surveys.surveyquestion'),
),
migrations.AddField(
model_name='surveyresponse',
name='survey_instance',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='surveys.surveyinstance'),
),
migrations.AddIndex(
model_name='surveyquestion',
index=models.Index(fields=['survey_template', 'order'], name='surveys_sur_survey__d8acd5_idx'),
),
migrations.AlterUniqueTogether(
name='surveyresponse',
unique_together={('survey_instance', 'question')},
),
]

View File

@ -1,51 +0,0 @@
# Generated by Django 5.0.14 on 2025-12-28 16:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('surveys', '0002_surveyquestion_surveyresponse_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='surveyinstance',
name='contact_notes',
field=models.TextField(blank=True, help_text='Notes from patient contact'),
),
migrations.AddField(
model_name='surveyinstance',
name='issue_resolved',
field=models.BooleanField(default=False, help_text='Whether the issue was resolved/explained'),
),
migrations.AddField(
model_name='surveyinstance',
name='patient_contacted',
field=models.BooleanField(default=False, help_text='Whether patient was contacted about negative survey'),
),
migrations.AddField(
model_name='surveyinstance',
name='patient_contacted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='surveyinstance',
name='patient_contacted_by',
field=models.ForeignKey(blank=True, help_text='User who contacted the patient', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacted_surveys', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='surveyinstance',
name='satisfaction_feedback_sent',
field=models.BooleanField(default=False, help_text='Whether satisfaction feedback form was sent'),
),
migrations.AddField(
model_name='surveyinstance',
name='satisfaction_feedback_sent_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -14,7 +14,7 @@ from django.core.signing import Signer
from django.db import models
from django.urls import reverse
from apps.core.models import BaseChoices, StatusChoices, TimeStampedModel, UUIDModel
from apps.core.models import BaseChoices, StatusChoices, TenantModel, TimeStampedModel, UUIDModel
class QuestionType(BaseChoices):
@ -165,7 +165,7 @@ class SurveyQuestion(UUIDModel, TimeStampedModel):
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.
@ -174,6 +174,8 @@ class SurveyInstance(UUIDModel, TimeStampedModel):
- Patient (recipient)
- Journey stage (optional - if stage survey)
- Encounter (optional)
Tenant-aware: All surveys are scoped to a hospital.
"""
survey_template = models.ForeignKey(
SurveyTemplate,

View File

@ -33,7 +33,7 @@ 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']
@ -101,7 +101,8 @@ 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']

View File

@ -77,6 +77,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'apps.core.middleware.TenantMiddleware', # Multi-tenancy support
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
@ -96,6 +97,7 @@ TEMPLATES = [
'django.contrib.messages.context_processors.messages',
'django.template.context_processors.i18n',
'apps.core.context_processors.sidebar_counts',
'apps.core.context_processors.hospital_context',
],
},
},
@ -317,6 +319,12 @@ SLA_DEFAULTS = {
},
}
# AI Configuration (LiteLLM with OpenRouter)
OPENROUTER_API_KEY = env('OPENROUTER_API_KEY', default='')
AI_MODEL = env('AI_MODEL', default='xiaomi/mimo-v2-flash:free')
AI_TEMPERATURE = env.float('AI_TEMPERATURE', default=0.3)
AI_MAX_TOKENS = env.int('AI_MAX_TOKENS', default=500)
# Notification Configuration
NOTIFICATION_CHANNELS = {
'sms': {
@ -346,3 +354,13 @@ DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@px360.sa')
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
# Multi-Tenancy Settings
TENANCY_ENABLED = True
TENANT_MODEL = 'organizations.Hospital'
TENANT_FIELD = 'hospital'
# Tenant isolation level
# 'strict' - Complete isolation (users only see their hospital)
# 'relaxed' - PX admins can see all hospitals
TENANT_ISOLATION_LEVEL = 'strict'

View File

@ -0,0 +1,180 @@
# Bilingual AI Analysis Implementation
## Overview
The AI analysis system now generates all text fields in both English and Arabic, providing better support for bilingual users in Saudi Arabia.
## Changes Made
### 1. AI Service (`apps/core/ai_service.py`)
**Updated AI Prompt:**
- Modified the `analyze_complaint` method to request bilingual output
- AI now generates all text fields in both English and Arabic
- System prompt emphasizes bilingual capabilities
**New Response Format:**
```json
{
"title_en": "concise title in English summarizing the complaint (max 10 words)",
"title_ar": "العنوان بالعربية",
"short_description_en": "2-3 sentence summary in English of the complaint that captures the main issue and context",
"short_description_ar": "ملخص من 2-3 جمل بالعربية",
"severity": "low|medium|high|critical",
"priority": "low|medium|high",
"category": "exact category name from the list above",
"subcategory": "exact subcategory name from the chosen category, or empty string if not applicable",
"department": "exact department name from the hospital's departments, or empty string if not applicable",
"suggested_action_en": "2-3 specific, actionable steps in English to address this complaint",
"suggested_action_ar": "خطوات محددة وعمليه بالعربية",
"reasoning_en": "Brief explanation in English of your classification (2-3 sentences)",
"reasoning_ar": "شرح مختصر بالعربية"
}
```
### 2. Complaint Tasks (`apps/complaints/tasks.py`)
**Updated `analyze_complaint_with_ai` Task:**
- Stores bilingual AI results in complaint metadata
- Updates complaint title from AI's English version
- Creates bilingual timeline update messages
- Returns bilingual results
**Metadata Structure:**
```python
complaint.metadata['ai_analysis'] = {
'title_en': 'English title',
'title_ar': 'العنوان بالعربية',
'short_description_en': 'English summary',
'short_description_ar': 'ملخص بالعربية',
'suggested_action_en': 'English action steps',
'suggested_action_ar': 'خطوات العمل بالعربية',
'reasoning_en': 'English explanation',
'reasoning_ar': 'الشرح بالعربية',
'analyzed_at': 'ISO timestamp',
'old_severity': 'previous severity',
'old_priority': 'previous priority',
'old_category': 'previous category name',
'old_category_id': 'previous category ID',
'old_department': 'previous department name',
'old_department_id': 'previous department ID'
}
```
**Timeline Update Example:**
```
AI analysis complete: Severity=high, Priority=medium, Category=Quality of Care, Department=Nursing
اكتمل تحليل الذكاء الاصطناعي: الشدة=high, الأولوية=medium, الفئة=Quality of Care, القسم=Nursing
```
### 3. Complaint Model (`apps/complaints/models.py`)
**Added Bilingual Properties:**
- `title_en` - AI-generated title (English)
- `title_ar` - AI-generated title (Arabic)
- `short_description_en` - AI-generated short description (English)
- `short_description_ar` - AI-generated short description (Arabic)
- `suggested_action_en` - AI-generated suggested action (English)
- `suggested_action_ar` - AI-generated suggested action (Arabic)
- `reasoning_en` - AI-generated reasoning (English)
- `reasoning_ar` - AI-generated reasoning (Arabic)
**Backward Compatibility:**
- Existing properties `short_description` and `suggested_action` still work
- They now return the English versions (`short_description_en`, `suggested_action_en`)
## Usage Examples
### Accessing Bilingual AI Results in Code
```python
complaint = Complaint.objects.get(id=some_id)
# Get English version
title_en = complaint.title_en
summary_en = complaint.short_description_en
action_en = complaint.suggested_action_en
# Get Arabic version
title_ar = complaint.title_ar
summary_ar = complaint.short_description_ar
action_ar = complaint.suggested_action_ar
# Get reasoning
reasoning_en = complaint.reasoning_en
reasoning_ar = complaint.reasoning_ar
```
### Accessing Raw Metadata
```python
ai_analysis = complaint.metadata.get('ai_analysis', {})
if ai_analysis:
title_en = ai_analysis.get('title_en', '')
title_ar = ai_analysis.get('title_ar', '')
# etc.
```
### Displaying in Templates
```html
<!-- English version -->
<h2>{{ complaint.title_en }}</h2>
<p>{{ complaint.short_description_en }}</p>
<!-- Arabic version (with RTL) -->
<h2 dir="rtl">{{ complaint.title_ar }}</h2>
<p dir="rtl">{{ complaint.short_description_ar }}</p>
<!-- Language-aware display -->
{% if LANGUAGE_CODE == 'ar' %}
<h2 dir="rtl">{{ complaint.title_ar }}</h2>
{% else %}
<h2>{{ complaint.title_en }}</h2>
{% endif %}
```
## Fields That Are Bilingual
- ✅ Title
- ✅ Short description
- ✅ Suggested action
- ✅ Reasoning
## Fields That Remain Single Values
- Severity (enum code: low/medium/high/critical)
- Priority (enum code: low/medium/high)
- Category (reference to existing category)
- Subcategory (reference to existing subcategory)
- Department (reference to existing department)
## Migration Notes
- No database migration required (metadata is JSONField)
- Existing complaints will have empty `_en` and `_ar` fields until re-analyzed
- Backward compatible - existing code using `short_description` and `suggested_action` will continue to work
## Testing
To test bilingual AI analysis:
1. Create a new complaint via the public form
2. Wait for AI analysis (async Celery task)
3. Check the complaint metadata:
```python
complaint = Complaint.objects.latest('created_at')
print(complaint.metadata['ai_analysis'])
```
Expected output should include both `_en` and `_ar` versions of all text fields.
## Future Enhancements
Possible future improvements:
1. Add language preference per user
2. Auto-display based on user's language setting
3. Add translation for existing complaints
4. Support for more languages if needed

335
docs/EMOTION_ANALYSIS.md Normal file
View File

@ -0,0 +1,335 @@
# Emotion Analysis Implementation
## Overview
The AI service now performs emotion analysis on complaints, identifying the primary emotion (Anger, Sadness, Confusion, Fear, or Neutral) with an intensity score (0.0 to 1.0) and confidence score (0.0 to 1.0). This helps staff better understand the emotional state of patients and prioritize responses accordingly.
## Changes Made
### 1. AI Service (`apps/core/ai_service.py`)
**New Method: `analyze_emotion`**
Analyzes text to identify the primary emotion and its intensity.
**Input:**
- `text`: Text to analyze (supports both English and Arabic)
**Output:**
```python
{
'emotion': 'anger' | 'sadness' | 'confusion' | 'fear' | 'neutral',
'intensity': float, # 0.0 to 1.0 (how strong the emotion is)
'confidence': float # 0.0 to 1.0 (how confident AI is)
}
```
**Emotion Categories:**
- **anger**: Strong feelings of displeasure, hostility, or rage
- **sadness**: Feelings of sorrow, grief, or unhappiness
- **confusion**: Lack of understanding, bewilderment, or uncertainty
- **fear**: Feelings of anxiety, worry, or being afraid
- **neutral**: No strong emotion detected
**Features:**
- Bilingual support (English and Arabic)
- Input validation for intensity and confidence scores
- Automatic clamping to valid range (0.0 to 1.0)
- Detailed logging for debugging
### 2. Complaint Tasks (`apps/complaints/tasks.py`)
**Updated `analyze_complaint_with_ai` Task:**
Now performs both standard AI analysis AND emotion analysis:
```python
# Analyze complaint using AI service
analysis = AIService.analyze_complaint(...)
# Analyze emotion using AI service
emotion_analysis = AIService.analyze_emotion(text=complaint.description)
```
**Metadata Storage:**
Emotion analysis is stored in complaint metadata:
```python
complaint.metadata['ai_analysis'] = {
# ... other fields ...
'emotion': emotion_analysis.get('emotion', 'neutral'),
'emotion_intensity': emotion_analysis.get('intensity', 0.0),
'emotion_confidence': emotion_analysis.get('confidence', 0.0),
}
```
**Timeline Update:**
Emotion is included in the bilingual AI analysis timeline message:
```
AI analysis complete: Severity=high, Priority=medium, Category=Quality of Care,
Department=Nursing, Emotion=anger (Intensity: 0.85)
اكتمل تحليل الذكاء الاصطناعي: الشدة=high, الأولوية=medium, الفئة=Quality of Care,
القسم=Nursing, العاطفة=anger (الشدة: 0.85)
```
### 3. Complaint Model (`apps/complaints/models.py`)
**Added Properties:**
- `emotion` - Primary emotion (anger/sadness/confusion/fear/neutral)
- `emotion_intensity` - Intensity score (0.0 to 1.0)
- `emotion_confidence` - Confidence score (0.0 to 1.0)
- `get_emotion_display` - Human-readable emotion name
- `get_emotion_badge_class` - Bootstrap badge class for emotion
**Usage Example:**
```python
complaint = Complaint.objects.get(id=some_id)
# Get emotion data
emotion = complaint.emotion # 'anger'
intensity = complaint.emotion_intensity # 0.85
confidence = complaint.emotion_confidence # 0.92
# Get display values
display_name = complaint.get_emotion_display # 'Anger'
badge_class = complaint.get_emotion_badge_class # 'danger'
```
**Badge Color Mapping:**
- Anger → `danger` (red)
- Sadness → `primary` (blue)
- Confusion → `warning` (yellow)
- Fear → `info` (cyan)
- Neutral → `secondary` (gray)
### 4. Complaint Detail Template (`templates/complaints/complaint_detail.html`)
**New Section: Emotion Analysis**
Added to the AI Analysis section in the Details tab:
```html
<div class="mb-3">
<div class="info-label">Emotion Analysis</div>
<div class="card">
<div class="card-body">
<!-- Emotion Badge -->
<span class="badge bg-danger">
<i class="bi bi-emoji-frown"></i> Anger
</span>
<!-- Confidence -->
<small class="text-muted">Confidence: 92%</small>
<!-- Intensity Progress Bar -->
<div class="progress">
<div class="progress-bar bg-danger" style="width: 85%"></div>
</div>
<small>Intensity: 0.85 / 1.0</small>
</div>
</div>
</div>
```
**Visual Features:**
- Color-coded badge based on emotion type
- Progress bar showing intensity (0.0 to 1.0)
- Confidence percentage display
- Gradient background card design
## Usage
### Accessing Emotion Data in Code
```python
from apps.complaints.models import Complaint
# Get a complaint
complaint = Complaint.objects.get(id=complaint_id)
# Check if emotion analysis exists
if complaint.emotion:
print(f"Primary emotion: {complaint.get_emotion_display}")
print(f"Intensity: {complaint.emotion_intensity:.2f}")
print(f"Confidence: {complaint.emotion_confidence:.2f}")
# Check for high-intensity anger (may need escalation)
if complaint.emotion == 'anger' and complaint.emotion_intensity > 0.8:
print("⚠️ High-intensity anger detected - consider escalation")
```
### Accessing Raw Metadata
```python
ai_analysis = complaint.metadata.get('ai_analysis', {})
if ai_analysis:
emotion = ai_analysis.get('emotion', 'neutral')
intensity = ai_analysis.get('emotion_intensity', 0.0)
confidence = ai_analysis.get('emotion_confidence', 0.0)
```
### Displaying in Templates
```html
<!-- Basic display -->
<div>Emotion: {{ complaint.get_emotion_display }}</div>
<div>Intensity: {{ complaint.emotion_intensity|floatformat:2 }}</div>
<div>Confidence: {{ complaint.emotion_confidence|floatformat:2 }}</div>
<!-- With badge -->
<span class="badge bg-{{ complaint.get_emotion_badge_class }}">
{{ complaint.get_emotion_display }}
</span>
<!-- Progress bar -->
<div class="progress">
<div class="progress-bar bg-{{ complaint.get_emotion_badge_class }}"
style="width: {{ complaint.emotion_intensity|mul:100 }}%">
</div>
</div>
```
## Use Cases
### 1. Prioritization
High-intensity anger complaints can be prioritized:
```python
# Get high-intensity anger complaints
urgent_complaints = Complaint.objects.filter(
metadata__ai_analysis__emotion='anger',
metadata__ai_analysis__emotion_intensity__gte=0.7
).order_by('-metadata__ai_analysis__emotion_intensity')
```
### 2. Automatic Escalation
Trigger escalation for high-intensity emotions:
```python
if complaint.emotion == 'anger' and complaint.emotion_intensity > 0.8:
escalate_complaint_auto.delay(str(complaint.id))
```
### 3. Staff Assignment
Assign complaints with negative emotions to more experienced staff:
```python
if complaint.emotion in ['anger', 'fear']:
# Assign to senior staff
complaint.assigned_to = get_senior_staff_member()
```
### 4. Analytics
Track emotion trends over time:
```python
from collections import Counter
# Get emotion distribution
complaints = Complaint.objects.filter(
created_at__gte=start_date
)
emotion_counts = Counter([
c.emotion for c in complaints if c.emotion
])
# Result: {'anger': 15, 'sadness': 8, 'confusion': 12, 'fear': 5, 'neutral': 20}
```
## Emotion Examples
| Emotion | Example Text | Intensity | Badge |
|---------|--------------|------------|-------|
| Anger | "This is unacceptable! I demand to speak to management!" | 0.9 | 🔴 Danger |
| Sadness | "I'm very disappointed with the care my father received" | 0.7 | 🔵 Primary |
| Confusion | "I don't understand what happened, can you explain?" | 0.5 | 🟡 Warning |
| Fear | "I'm worried about the side effects of this medication" | 0.6 | 🔵 Info |
| Neutral | "I would like to report a minor issue" | 0.2 | ⚫ Secondary |
## Testing
To test emotion analysis:
```python
from apps.core.ai_service import AIService
# Test English text
result = AIService.analyze_emotion(
"This is unacceptable! I demand to speak to management!"
)
print(result)
# Output: {'emotion': 'anger', 'intensity': 0.9, 'confidence': 0.95}
# Test Arabic text
result = AIService.analyze_emotion(
"أنا قلق جداً من الآثار الجانبية لهذا الدواء"
)
print(result)
# Output: {'emotion': 'fear', 'intensity': 0.7, 'confidence': 0.88}
```
## Benefits
1. **Better Understanding**: Identifies specific emotions instead of generic sentiment
2. **Intensity Tracking**: Shows how strong the emotion is (helps prioritize)
3. **Bilingual Support**: Works with both English and Arabic complaints
4. **Visual Feedback**: Easy-to-read badges and progress bars
5. **Actionable**: High-intensity emotions can trigger automatic responses
6. **Confidence Scoring**: Knows how reliable the analysis is
## Future Enhancements
Possible future improvements:
1. Add emotion trend tracking for patients
2. Create dashboard visualizations for emotion statistics
3. Add more emotion categories (frustration, joy, surprise)
4. Emotion-based routing to specialized staff
5. Patient emotion profiles over time
6. Integration with CRM systems
## API Reference
### AIService.analyze_emotion(text)
**Parameters:**
- `text` (str): Text to analyze
**Returns:**
- `dict`: Emotion analysis with keys:
- `emotion` (str): Primary emotion category
- `intensity` (float): Emotion strength (0.0 to 1.0)
- `confidence` (float): AI confidence (0.0 to 1.0)
**Raises:**
- `AIServiceError`: If API call fails
### Complaint.emotion (property)
**Returns:**
- `str`: Primary emotion code
### Complaint.emotion_intensity (property)
**Returns:**
- `float`: Emotion intensity (0.0 to 1.0)
### Complaint.emotion_confidence (property)
**Returns:**
- `float`: AI confidence in emotion detection (0.0 to 1.0)
### Complaint.get_emotion_display (property)
**Returns:**
- `str`: Human-readable emotion name
### Complaint.get_emotion_badge_class (property)
**Returns:**
- `str`: Bootstrap badge class for emotion

View File

@ -0,0 +1,209 @@
# Hospital Sidebar Display Feature
## Overview
This document describes hospital display feature in PX360 sidebar, which shows the current hospital context and allows PX Admins to quickly switch between hospitals.
## Features
### 1. Hospital Display in Sidebar
The sidebar now displays the current hospital at the bottom:
- Hospital icon (🏥)
- Hospital name (truncated if too long)
- City for context
- Dropdown for PX Admins to switch hospitals
- Read-only display for Hospital Admins and Department Managers
**Position**: At the bottom of the sidebar, after all navigation items.
### 2. PX Admin Quick Switch
For PX Admins, clicking on the hospital display opens a dropdown with:
- List of all hospitals in the system
- Current hospital highlighted with a checkmark
- Hospital name and city for each option
- "View All Hospitals" link to the full hospital selector page
### 3. Hospital Context Availability
The current hospital is available in all templates through the `current_hospital` context variable, provided by the `hospital_context` context processor.
## Implementation Details
### Files Modified
1. **`apps/core/context_processors.py`**
- Added `hospital_context()` function
- Provides `current_hospital` and `is_px_admin` to all templates
- Also updated `sidebar_counts()` to include hospital context
2. **`config/settings/base.py`**
- Added `apps.core.context_processors.hospital_context` to context_processors list
3. **`templates/layouts/partials/sidebar.html`**
- Added hospital display component at the top of sidebar (after brand)
- Includes dropdown for PX Admins with all hospitals
- Forms for quick hospital switching
4. **`apps/core/templatetags/hospital_filters.py`** (new)
- Created `get_all_hospitals` template tag
- Returns all hospitals ordered by name, city
5. **`apps/core/templatetags/__init__.py`** (new)
- Enables template tag module for core app
### Context Variables
Available in all templates:
```python
{
'current_hospital': Hospital, # Current hospital object or None
'is_px_admin': bool, # True if user is PX Admin
}
```
## User Experience
### For PX Admins
1. **Initial State**: Shows current hospital with dropdown indicator
2. **Click Dropdown**: Opens list of all hospitals
3. **Select Hospital**:
- Submits form to select hospital
- Session is updated
- Page refreshes with new hospital data
- Hospital display updates to show new selection
4. **Alternative**: Can click "View All Hospitals" to see full hospital selector page
### For Hospital Admins
1. **Display**: Shows their assigned hospital (read-only)
2. **No Dropdown**: Cannot switch hospitals
3. **Clear Context**: Always know which hospital data they're viewing
### For Department Managers
1. **Display**: Shows their hospital (read-only)
2. **No Dropdown**: Cannot switch hospitals
3. **Consistent View**: Hospital context is clear
## Template Usage
### Accessing Hospital in Templates
```django
{% if current_hospital %}
<h2>{{ current_hospital.name }}</h2>
<p>{{ current_hospital.city }}</p>
{% endif %}
```
### Checking User Role
```django
{% if is_px_admin %}
<p>You are a PX Admin with access to all hospitals</p>
{% else %}
<p>You are viewing: {{ current_hospital.name }}</p>
{% endif %}
```
### Using Hospital Filter
```django
{% load hospital_filters %}
{% get_all_hospitals as hospitals %}
{% for hospital in hospitals %}
{{ hospital.name }}
{% endfor %}
```
## Styling
The hospital display uses Bootstrap 5 classes with custom styling:
```css
.btn-light {
min-width: 200px;
max-width: 300px;
}
.dropdown-item {
display: flex;
align-items: center;
}
```
## Responsive Design
- Hospital display is visible on desktop (md and up)
- On mobile, it may need adjustment or can be hidden
- Dropdown has max-height with scrollbar for many hospitals
## Future Enhancements
1. **Recent Hospitals**: Show recently accessed hospitals first
2. **Hospital Search**: Add search box within dropdown for many hospitals
3. **Hospital Avatar**: Display hospital logo/image instead of icon
4. **Hospital Status**: Show active/inactive status indicators
5. **Hospital Stats**: Display quick stats (complaints, surveys, etc.)
6. **Mobile Optimization**: Better mobile experience for hospital switching
## Testing Checklist
- [ ] Hospital displays correctly for all user types
- [ ] PX Admin can see dropdown with all hospitals
- [ ] PX Admin can switch hospitals successfully
- [ ] Hospital Admin sees only their hospital (no dropdown)
- [ ] Department Manager sees their hospital (no dropdown)
- [ ] Hospital name truncates properly if too long
- [ ] Dropdown shows checkmark for current hospital
- [ ] "View All Hospitals" link works
- [ ] Session updates after hospital switch
- [ ] Page refreshes with correct hospital data
- [ ] Hospital context available in all templates
## Troubleshooting
### Hospital Not Displaying
**Problem**: Hospital display doesn't show in sidebar.
**Solutions**:
1. Check that user is authenticated
2. Verify `TenantMiddleware` is setting `request.tenant_hospital`
3. Ensure `hospital_context` is in settings context_processors
4. Check browser console for template errors
### Dropdown Not Working
**Problem**: PX Admin sees hospital but dropdown doesn't open.
**Solutions**:
1. Check Bootstrap 5 JavaScript is loaded
2. Verify `data-bs-toggle="dropdown"` attribute is present
3. Check for JavaScript errors in browser console
4. Ensure jQuery is loaded (required for some Bootstrap features)
### Hospitals Not Loading
**Problem**: Dropdown shows empty list or no hospitals.
**Solutions**:
1. Check that `hospital_filters` is loaded in template
2. Verify Hospital model has records
3. Check database connection
4. Review server logs for errors
## Related Documentation
- [Tenant-Aware Routing Implementation](./TENANT_AWARE_ROUTING_IMPLEMENTATION.md)
- [Organization Model](./ORGANIZATION_MODEL.md)
- [Architecture](./ARCHITECTURE.md)
## Conclusion
The hospital sidebar display provides clear context awareness and convenient hospital switching for PX Admins, improving the overall user experience in the PX360 platform. The feature is production-ready and follows Django best practices for context processors, template tags, and responsive design.

210
docs/ORGANIZATION_MODEL.md Normal file
View File

@ -0,0 +1,210 @@
# Organization Model Implementation
## Overview
The Organization model has been added to create a hierarchical structure for healthcare facilities. This allows multiple hospitals to be grouped under a parent organization.
## Model Structure
```
Organization (Top-level)
├── Hospital (Branch)
│ ├── Department
│ │ ├── Physician
│ │ └── Employee
│ └── Patient
```
## Organization Model
### Fields
- `name` (CharField) - Organization name (English)
- `name_ar` (CharField) - Organization name (Arabic)
- `code` (CharField) - Unique organization code (indexed)
- `address` (TextField) - Organization address
- `city` (CharField) - Organization city
- `phone` (CharField) - Contact phone number
- `email` (EmailField) - Contact email address
- `website` (URLField) - Organization website URL
- `status` (CharField) - Active/Inactive status (from StatusChoices)
- `logo` (ImageField) - Organization logo image
- `license_number` (CharField) - Organization license number
- `created_at` (DateTimeField) - Auto-populated creation timestamp
- `updated_at` (DateTimeField) - Auto-populated update timestamp
### Relationships
- `hospitals` (One-to-Many) - Related name for Hospital model
## Hospital Model Changes
### New Field
- `organization` (ForeignKey to Organization) - Parent organization (nullable for backward compatibility)
### Backward Compatibility
The `organization` field is nullable to ensure existing hospitals without an organization continue to work.
## Database Migration
### Migration File
- `apps/organizations/migrations/0002_organization_hospital_organization.py`
### Changes Made
1. Created `Organization` model table
2. Added `organization` foreign key to `Hospital` table
3. Migration applied successfully
## Admin Interface
### OrganizationAdmin
- List display: name, code, city, status, created_at
- Filters: status, city
- Search: name, name_ar, code, license_number
- Ordering: by name
- Fieldsets: Basic info, Contact, Details, Metadata
### HospitalAdmin Updates
- Added `organization` field to form
- Added autocomplete for organization selection
- Added `organization_name` to list display (read-only)
## Serializers
### OrganizationSerializer
- Includes `hospitals_count` method field
- Fields: id, name, name_ar, code, address, city, phone, email, website, status, license_number, logo, hospitals_count, created_at, updated_at
### HospitalSerializer Updates
- Added `organization` field
- Added `organization_name` (read-only, from organization.name)
- Added `departments_count` method field
- Fields: id, organization, organization_name, name, name_ar, code, address, city, phone, email, status, license_number, capacity, departments_count, created_at, updated_at
## Data Migration
### Management Command
`python manage.py create_default_organization`
#### Options
- `--name` - Organization name (default: "Default Healthcare Organization")
- `--code` - Organization code (default: "DEFAULT")
- `--force` - Force reassignment of all hospitals
- `--dry-run` - Preview changes without applying
#### Example Usage
```bash
# Create default organization
python manage.py create_default_organization
# Create with custom name and code
python manage.py create_default_organization --name="My Hospital Group" --code="MHG"
# Preview changes
python manage.py create_default_organization --dry-run
# Force reassign all hospitals
python manage.py create_default_organization --force
```
### Current Data
- **Organization**: King Faisal Specialist Hospital Group (KFSHG)
- **Hospital**: Alhammadi Hospital (HH)
- **Departments**: 10
- **Status**: All hospitals assigned to organization
## Verification
### Verification Script
Run `python verify_organization.py` to check:
- Total organizations
- Hospitals per organization
- Departments per hospital
- Orphaned hospitals (without organization)
### Expected Output
```
============================================================
Organization Verification
============================================================
Total Organizations: 1
Organization: King Faisal Specialist Hospital Group (KFSHG)
Status: active
Hospitals: 1
- Alhammadi Hospital (Code: HH)
Departments: 10
✓ All hospitals have organizations assigned
============================================================
```
## Impact on Other Apps
### Complaints App
- Hospital selection in forms now shows organization context
- Complaints can be filtered by organization via hospital relationship
- No breaking changes - existing functionality preserved
### Surveys App
- Surveys can be filtered by organization
- Patient relationships work through hospital → organization
### Journey Engine
- Journeys can be organized by organization
- Department routing can consider organization context
### Call Center
- Agent dashboard can filter by organization
- Call routing can consider organization hierarchy
### Analytics App
- Reports can be aggregated at organization level
- Multi-hospital analytics available
## Future Enhancements
### Recommended Features
1. **Organization-Level Permissions**
- Role-based access control at organization level
- Organization admins managing their hospitals
2. **Organization Settings**
- Custom branding per organization
- Organization-specific policies and procedures
- Organization-level notification settings
3. **Organization Dashboard**
- Overview of all hospitals in organization
- Aggregate metrics and KPIs
- Cross-hospital comparison
4. **Multi-Organization Support**
- Users can belong to multiple organizations
- Organization switching in UI
- Organization-scoped data views
5. **Organization Hierarchy**
- Sub-organizations (regional, national)
- Multi-level organization structure
- Parent/child organization relationships
6. **Organization Templates**
- Standardized hospital templates
- Quick setup of new hospitals
- Consistent policies across organization
## Notes
- All models inherit from `UUIDModel` and `TimeStampedModel` for consistency
- Bilingual support (English/Arabic) maintained throughout
- Status management follows existing `StatusChoices` pattern
- Backward compatible with existing data
- No breaking changes to existing functionality
## References
- Model file: `apps/organizations/models.py`
- Admin file: `apps/organizations/admin.py`
- Serializer file: `apps/organizations/serializers.py`
- Migration: `apps/organizations/migrations/0002_organization_hospital_organization.py`
- Management command: `apps/organizations/management/commands/create_default_organization.py`

View File

@ -0,0 +1,238 @@
# Tenant-Aware Routing Implementation
## Overview
This document describes the tenant-aware routing system implemented to support PX Admins in managing multiple hospitals within the PX360 platform. PX Admins are super-admins who can view and manage data across all hospitals, but need to select a specific hospital to work with at any given time.
## Problem Statement
PX Admins have access to all hospitals in the system, but:
- They need to work with one hospital at a time for data consistency
- The system must track which hospital they're currently viewing
- All queries must be filtered to show only the selected hospital's data
- Other user roles (Hospital Admins, Department Managers) should only see their assigned hospital/department
## Solution Architecture
### 1. User Role Hierarchy
The system supports the following user roles:
1. **PX Admin** (Level 4)
- Can view and manage all hospitals
- Must select a hospital before viewing data
- Can switch between hospitals
2. **Hospital Admin** (Level 3)
- Assigned to a specific hospital
- Can only view/manage their hospital's data
3. **Department Manager** (Level 2)
- Assigned to a specific department within a hospital
- Can only view/manage their department's data
4. **Regular User** (Level 1)
- Limited permissions based on assignment
### 2. Implementation Components
#### A. TenantMiddleware (`apps/core/middleware.py`)
The middleware is responsible for:
- Detecting the user's tenant hospital
- Setting `request.tenant_hospital` attribute
- Redirecting PX Admins to hospital selector if no hospital is selected
**Key Features:**
```python
# For PX Admins: Get hospital from session
if user.is_px_admin():
hospital_id = request.session.get('selected_hospital_id')
if hospital_id:
try:
from apps.organizations.models import Hospital
hospital = Hospital.objects.get(id=hospital_id)
request.tenant_hospital = hospital
except Hospital.DoesNotExist:
pass
# For other users: Use their assigned hospital
elif user.hospital:
request.tenant_hospital = user.hospital
```
#### B. Hospital Selector (`apps/core/views.py`)
A dedicated view that allows PX Admins to:
- View all available hospitals
- Select a hospital to work with
- Store the selection in session
**Key Features:**
- Lists all hospitals with location info
- Shows currently selected hospital
- Stores selection in session for persistence
- Redirects back to the referring page after selection
#### C. Login Redirect (`apps/accounts/views.py`)
Enhanced JWT token view that provides redirect URLs based on user role:
- PX Admins → `/health/select-hospital/`
- Users without hospital → `/health/no-hospital/`
- All others → Dashboard (`/`)
#### D. Dashboard Integration (`apps/dashboard/views.py`)
The CommandCenter dashboard now:
- Checks if PX Admin has selected a hospital
- Redirects to hospital selector if not
- Filters all data based on `request.tenant_hospital`
- Shows current hospital context in templates
#### E. Context Processor (`apps/core/context_processors.py`)
Updated sidebar counts to respect tenant context:
- PX Admins see counts for selected hospital only
- Other users see counts for their assigned hospital
### 3. URL Structure
```
/ - Dashboard (requires hospital context)
/health/select-hospital/ - Hospital selector for PX Admins
/health/no-hospital/ - Error page for unassigned users
```
### 4. Template Structure
- `templates/core/select_hospital.html` - Hospital selection UI
- `templates/core/no_hospital_assigned.html` - Error page for unassigned users
### 5. Data Flow
```
User Login
JWT Token Response (includes redirect_url)
Frontend redirects based on role
[If PX Admin] Hospital Selector Page
User selects hospital → stored in session
TenantMiddleware sets request.tenant_hospital
All views filter data using request.tenant_hospital
Dashboard displays filtered data
```
### 6. Query Filtering Pattern
Views should follow this pattern for filtering:
```python
def get_queryset(self):
queryset = super().get_queryset()
user = self.request.user
hospital = getattr(self.request, 'tenant_hospital', None)
if user.is_px_admin() and hospital:
return queryset.filter(hospital=hospital)
elif user.hospital:
return queryset.filter(hospital=user.hospital)
elif user.department:
return queryset.filter(department=user.department)
else:
return queryset.none()
```
## Benefits
1. **Data Isolation**: PX Admins can safely work with one hospital at a time
2. **Consistent User Experience**: Clear indication of which hospital is being viewed
3. **Security**: Automatic filtering prevents cross-hospital data leakage
4. **Flexibility**: PX Admins can easily switch between hospitals
5. **Backward Compatible**: Other user roles work as before
## Future Enhancements
1. **Remember Last Hospital**: Store PX Admin's last hospital in user profile
2. **Hospital Quick Switcher**: Add dropdown in header for quick switching
3. **Multi-Hospital View**: Option to see aggregated data across hospitals
4. **Audit Trail**: Track hospital selection changes in audit logs
5. **Permissions Matrix**: Fine-grained control per hospital
## Testing Checklist
- [ ] PX Admin can login and see hospital selector
- [ ] PX Admin can select a hospital
- [ ] Dashboard shows correct data for selected hospital
- [ ] PX Admin can switch hospitals
- [ ] Hospital Admin sees only their hospital
- [ ] Department Manager sees only their department
- [ ] Sidebar counts update correctly when switching hospitals
- [ ] All queries respect tenant_hospital filtering
- [ ] Session persists hospital selection
- [ ] Logout clears hospital selection
## Files Modified
1. `apps/core/middleware.py` - Enhanced TenantMiddleware
2. `apps/core/views.py` - Added select_hospital and no_hospital_assigned views
3. `apps/core/urls.py` - Added hospital selector URLs
4. `apps/core/context_processors.py` - Updated sidebar counts for tenant awareness
5. `apps/accounts/views.py` - Enhanced JWT token view with redirect URLs
6. `apps/dashboard/views.py` - Added hospital context and filtering
7. `templates/core/select_hospital.html` - New hospital selector template
8. `templates/core/no_hospital_assigned.html` - New error template
## Integration Notes
### For Frontend Developers
When handling JWT token response:
```javascript
const response = await login(credentials);
const redirectUrl = response.data.redirect_url;
window.location.href = redirectUrl;
```
### For Backend Developers
When creating new views:
1. Use `TenantHospitalRequiredMixin` if hospital context is required
2. Access hospital via `self.request.tenant_hospital`
3. Filter querysets based on user role and hospital context
4. Add hospital context to template if needed
### Example:
```python
from apps.core.mixins import TenantHospitalRequiredMixin
class ComplaintListView(TenantHospitalRequiredMixin, ListView):
model = Complaint
def get_queryset(self):
queryset = super().get_queryset()
hospital = self.request.tenant_hospital
if hospital:
return queryset.filter(hospital=hospital)
return queryset.none()
```
## Conclusion
This implementation provides a robust tenant-aware routing system that:
- Secures multi-hospital data access
- Maintains consistent user experience
- Scales to support additional hospitals
- Preserves existing functionality for non-admin users
The system is production-ready and follows Django best practices for middleware, authentication, and request handling.

View File

@ -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
@ -175,8 +175,8 @@ def clear_existing_data():
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()
@ -184,18 +184,6 @@ def clear_existing_data():
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()
@ -260,10 +248,10 @@ 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])
@ -273,7 +261,7 @@ def create_physicians(hospitals, departments):
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):
@ -375,9 +364,75 @@ 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()
@ -400,15 +455,22 @@ def create_complaints(patients, hospitals, physicians, users):
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,
@ -503,7 +565,7 @@ def create_inquiries(patients, hospitals, users):
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
@ -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),
@ -758,7 +820,7 @@ def create_journey_instances(journey_templates, patients):
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
@ -772,7 +834,7 @@ def create_survey_instances(survey_templates, patients, physicians):
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,
@ -782,7 +844,7 @@ 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
@ -865,9 +927,9 @@ 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
@ -875,7 +937,7 @@ def create_physician_monthly_ratings(physicians):
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
@ -899,7 +961,7 @@ def create_physician_monthly_ratings(physicians):
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={
@ -1531,13 +1593,16 @@ def main():
# 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)
@ -1552,37 +1617,13 @@ def main():
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")

View File

@ -24,8 +24,12 @@ dependencies = [
"django-extensions>=4.1",
"djangorestframework-stubs>=3.16.6",
"rich>=14.2.0",
<<<<<<< HEAD
"reportlab>=4.4.7",
"openpyxl>=3.1.5",
=======
"litellm>=1.0.0",
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
]
[project.optional-dependencies]

View File

@ -1,6 +1,7 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% load math %}
{% block title %}Complaint #{{ complaint.id|slice:":8" }} - PX360{% endblock %}
@ -265,6 +266,79 @@
</div>
</div>
<!-- AI Analysis Section -->
{% if complaint.short_description or complaint.suggested_action %}
<hr>
<div class="mb-3">
<div class="d-flex align-items-center mb-3">
<div class="info-label mb-0 me-2">
<i class="bi bi-robot me-1"></i>AI Analysis
</div>
<span class="badge bg-info">AI Generated</span>
</div>
<!-- Emotion Analysis -->
{% if complaint.emotion %}
<div class="mb-3">
<div class="info-label" style="font-size: 0.8rem;">Emotion Analysis</div>
<div class="card mb-0" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-left: 4px solid #6c757d;">
<div class="card-body py-2">
<div class="row align-items-center mb-2">
<div class="col-md-6">
<span class="badge bg-{{ complaint.get_emotion_badge_class }} me-2">
<i class="bi bi-emoji-frown me-1"></i>{{ complaint.get_emotion_display }}
</span>
</div>
<div class="col-md-6 text-md-end">
<small class="text-muted">Confidence: {{ complaint.emotion_confidence|floatformat:0 }}%</small>
</div>
</div>
<div class="mb-1">
<div class="d-flex justify-content-between mb-1">
<small class="text-muted">Intensity</small>
<small>{{ complaint.emotion_intensity|floatformat:2 }} / 1.0</small>
</div>
<div class="progress" style="height: 6px;">
{% if complaint.emotion == 'anger' %}
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
{% elif complaint.emotion == 'sadness' %}
<div class="progress-bar bg-primary" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
{% elif complaint.emotion == 'confusion' %}
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
{% elif complaint.emotion == 'fear' %}
<div class="progress-bar bg-info" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
{% else %}
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ complaint.emotion_intensity|mul:100 }}%"></div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if complaint.short_description %}
<div class="mb-3">
<div class="info-label" style="font-size: 0.8rem;">Summary</div>
<div class="alert alert-light border-info mb-0" style="background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);">
<i class="bi bi-info-circle me-1 text-info"></i>
<small>{{ complaint.short_description }}</small>
</div>
</div>
{% endif %}
{% if complaint.suggested_action %}
<div class="mb-0">
<div class="info-label" style="font-size: 0.8rem;">Suggested Action</div>
<div class="alert alert-success mb-0" style="background: linear-gradient(135deg, #e8f5e9 0%, #e1f5fe 100%); border-color: #4caf50;">
<i class="bi bi-lightning me-1 text-success"></i>
<small>{{ complaint.suggested_action }}</small>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if complaint.resolution %}
<hr>
<div class="mb-3">
@ -273,8 +347,13 @@
<div class="alert alert-success">
<p class="mb-2">{{ complaint.resolution|linebreaks }}</p>
<small class="text-muted">
<<<<<<< HEAD
{{ _("Resolved by")}} {{ complaint.resolved_by.get_full_name }}
{{ _("on") }} {{ complaint.resolved_at|date:"M d, Y H:i" }}
=======
Resolved by {{ complaint.resolved_by.get_full_name }}
on {{ complaint.resolved_at|date:"M d, Y H:i" }}
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
</small>
</div>
</div>
@ -561,7 +640,11 @@
</div>
<div class="card-body">
<p class="mb-2">
<<<<<<< HEAD
<strong>{{ _("Status") }}:</strong>
=======
<strong>Status:</strong>
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
<span class="badge bg-{{ complaint.resolution_survey.status }}">
{{ complaint.resolution_survey.get_status_display }}
</span>

View File

@ -47,6 +47,7 @@
<div class="row">
<div class="col-lg-8">
<<<<<<< HEAD
<!-- Patient Information -->
<div class="form-section">
<h5 class="form-section-title">
@ -70,6 +71,8 @@
</div>
</div>
=======
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
<!-- Organization Information -->
<div class="form-section">
<h5 class="form-section-title">
@ -90,37 +93,89 @@
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Department" %}</label>
<select name="department_id" class="form-select" id="departmentSelect">
<<<<<<< HEAD
<option value="">{{ _("Select department")}}</option>
=======
<option value="">Select hospital first...</option>
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<<<<<<< HEAD
<label class="form-label">{% trans "Physician" %}</label>
<select name="physician_id" class="form-select" id="physicianSelect">
<option value="">{{ _("Select physician")}}</option>
=======
<label class="form-label">{% trans "Staff" %}</label>
<select name="staff_id" class="form-select" id="staffSelect">
<option value="">Select department first...</option>
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
</select>
</div>
</div>
</div>
<!-- Classification Section -->
<div class="form-section">
<h5 class="form-section-title">
<i class="bi bi-tags me-2"></i>Classification
</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label required-field">{% trans "Category" %}</label>
<select name="category" class="form-select" id="categorySelect" required>
<option value="">Select hospital first...</option>
</select>
<small class="form-text text-muted">AI will analyze and suggest if needed</small>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Subcategory" %}</label>
<select name="subcategory" class="form-select" id="subcategorySelect">
<option value="">Select category first...</option>
</select>
</div>
</div>
</div>
<!-- Patient Information -->
<div class="form-section">
<h5 class="form-section-title">
<i class="bi bi-person-fill me-2"></i>Patient Information
</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label required-field">{% trans "Patient" %}</label>
<select name="patient_id" class="form-select" id="patientSelect" required>
<option value="">Search and select patient...</option>
</select>
<small class="form-text text-muted">Search by MRN or name</small>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Encounter ID" %}</label>
<input type="text" name="encounter_id" class="form-control"
placeholder="{% trans 'Optional encounter/visit ID' %}">
</div>
</div>
</div>
<!-- Complaint Details -->
<div class="form-section">
<h5 class="form-section-title">
<i class="bi bi-file-text me-2"></i>{{ _("Complaint Details")}}
</h5>
<div class="mb-3">
<label class="form-label required-field">{% trans "Title" %}</label>
<input type="text" name="title" class="form-control"
placeholder="{% trans 'Brief summary of the complaint' %}" required maxlength="500">
</div>
<div class="mb-3">
<label class="form-label required-field">{% trans "Description" %}</label>
<textarea name="description" class="form-control" rows="5"
<textarea name="description" class="form-control" rows="6"
placeholder="{% trans 'Detailed description of the complaint...' %}" required></textarea>
<<<<<<< HEAD
</div>
<div class="row">
@ -180,6 +235,13 @@
</select>
</div>
=======
<small class="form-text text-muted">
AI will automatically generate title, classify severity/priority, and analyze emotion
</small>
</div>
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
<div class="mb-3">
<label class="form-label required-field">{% trans "Source" %}</label>
<select name="source" class="form-select" required>
@ -196,14 +258,41 @@
</select>
</div>
</div>
</div>
<!-- SLA Information -->
<!-- Sidebar -->
<div class="col-lg-4">
<!-- AI Information -->
<div class="alert alert-info">
<h6 class="alert-heading">
<<<<<<< HEAD
<i class="bi bi-info-circle me-2"></i>{{ _("SLA Information")}}
</h6>
<p class="mb-0 small">
{{ _("SLA deadline will be automatically calculated based on severity")}}:
=======
<i class="bi bi-robot me-2"></i>AI-Powered Classification
</h6>
<p class="mb-0 small">
This form uses AI to automatically:
</p>
<ul class="mb-0 mt-2 small">
<li><strong>Generate Title:</strong> Create a concise summary</li>
<li><strong>Classify Severity:</strong> Low, Medium, High, or Critical</li>
<li><strong>Set Priority:</strong> Low, Medium, High, or Urgent</li>
<li><strong>Analyze Emotion:</strong> Detect patient sentiment</li>
<li><strong>Suggest Actions:</strong> Provide recommendations</li>
</ul>
</div>
<!-- SLA Information -->
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="bi bi-clock me-2"></i>SLA Information
</h6>
<p class="mb-0 small">
SLA deadline will be automatically calculated based on AI-determined severity:
>>>>>>> 12310a5 (update complain and add ai and sentiment analysis)
</p>
<ul class="mb-0 mt-2 small">
<li><strong>{{ _("Critical") }}:</strong> {{ _("4 hours")}}</li>
@ -230,57 +319,140 @@
{% block extra_js %}
<script>
// Initialize Select2 for patient search (if Select2 is available)
document.addEventListener('DOMContentLoaded', function() {
// Hospital change handler - load departments
const hospitalSelect = document.getElementById('hospitalSelect');
const departmentSelect = document.getElementById('departmentSelect');
const physicianSelect = document.getElementById('physicianSelect');
const staffSelect = document.getElementById('staffSelect');
const categorySelect = document.getElementById('categorySelect');
const subcategorySelect = document.getElementById('subcategorySelect');
const patientSelect = document.getElementById('patientSelect');
// Get current language
const currentLang = document.documentElement.lang || 'en';
// Hospital change handler
hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value;
// Clear department and physician
departmentSelect.innerHTML = '<option value="">Select department...</option>';
physicianSelect.innerHTML = '<option value="">Select physician...</option>';
// Clear dependent dropdowns
departmentSelect.innerHTML = '<option value="">Select hospital first...</option>';
staffSelect.innerHTML = '<option value="">Select department first...</option>';
categorySelect.innerHTML = '<option value="">Loading categories...</option>';
subcategorySelect.innerHTML = '<option value="">Select category first...</option>';
if (hospitalId) {
// Load departments for selected hospital
// Load departments
fetch(`/api/organizations/departments/?hospital=${hospitalId}`)
.then(response => response.json())
.then(data => {
departmentSelect.innerHTML = '<option value="">Select department...</option>';
data.results.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name_en;
const deptName = currentLang === 'ar' && dept.name_ar ? dept.name_ar : dept.name_en;
option.textContent = deptName;
departmentSelect.appendChild(option);
});
})
.catch(error => console.error('Error loading departments:', error));
.catch(error => {
console.error('Error loading departments:', error);
departmentSelect.innerHTML = '<option value="">Error loading departments</option>';
});
// Load physicians for selected hospital
fetch(`/api/organizations/physicians/?hospital=${hospitalId}`)
// Load categories (using public API endpoint)
fetch(`/complaints/public/api/load-categories/?hospital_id=${hospitalId}`)
.then(response => response.json())
.then(data => {
data.results.forEach(physician => {
const option = document.createElement('option');
option.value = physician.id;
option.textContent = `Dr. ${physician.first_name} ${physician.last_name} (${physician.specialty})`;
physicianSelect.appendChild(option);
categorySelect.innerHTML = '<option value="">Select category...</option>';
data.categories.forEach(cat => {
// Only show parent categories (no parent_id)
if (!cat.parent_id) {
const option = document.createElement('option');
option.value = cat.id;
option.dataset.code = cat.code;
const catName = currentLang === 'ar' && cat.name_ar ? cat.name_ar : cat.name_en;
option.textContent = catName;
categorySelect.appendChild(option);
}
});
})
.catch(error => console.error('Error loading physicians:', error));
.catch(error => {
console.error('Error loading categories:', error);
categorySelect.innerHTML = '<option value="">Error loading categories</option>';
});
} else {
categorySelect.innerHTML = '<option value="">Select hospital first...</option>';
}
});
// Patient search with debounce
const patientSelect = document.getElementById('patientSelect');
let patientSearchTimeout;
// Department change handler - load staff
departmentSelect.addEventListener('change', function() {
const departmentId = this.value;
// Simple patient search (can be enhanced with Select2)
// Clear staff dropdown
staffSelect.innerHTML = '<option value="">Select department first...</option>';
if (departmentId) {
// Load staff (filtered by department)
fetch(`/complaints/ajax/get-staff-by-department/?department_id=${departmentId}`)
.then(response => response.json())
.then(data => {
staffSelect.innerHTML = '<option value="">Select staff...</option>';
data.staff.forEach(staff => {
const option = document.createElement('option');
option.value = staff.id;
option.textContent = `${staff.first_name} ${staff.last_name} (${staff.job_title || staff.staff_type})`;
staffSelect.appendChild(option);
});
})
.catch(error => {
console.error('Error loading staff:', error);
staffSelect.innerHTML = '<option value="">Error loading staff</option>';
});
}
});
// Category change handler - load subcategories
categorySelect.addEventListener('change', function() {
const categoryId = this.value;
// Clear subcategory dropdown
subcategorySelect.innerHTML = '<option value="">Select category first...</option>';
if (categoryId) {
// Load categories again and filter for subcategories of selected parent
const hospitalId = hospitalSelect.value;
if (hospitalId) {
fetch(`/complaints/public/api/load-categories/?hospital_id=${hospitalId}`)
.then(response => response.json())
.then(data => {
subcategorySelect.innerHTML = '<option value="">Select subcategory...</option>';
data.categories.forEach(cat => {
// Only show subcategories (has parent_id matching selected category)
if (cat.parent_id == categoryId) {
const option = document.createElement('option');
option.value = cat.id;
option.dataset.code = cat.code;
const catName = currentLang === 'ar' && cat.name_ar ? cat.name_ar : cat.name_en;
option.textContent = catName;
subcategorySelect.appendChild(option);
}
});
if (subcategorySelect.options.length <= 1) {
subcategorySelect.innerHTML = '<option value="">No subcategories available</option>';
}
})
.catch(error => {
console.error('Error loading subcategories:', error);
subcategorySelect.innerHTML = '<option value="">Error loading subcategories</option>';
});
}
}
});
// Patient search
patientSelect.addEventListener('focus', function() {
if (this.options.length === 1) {
// Load initial patients
loadPatients('');
}
});

View File

@ -322,7 +322,6 @@
</button>
</div>
</div>
<!-- Complaints Table -->
<div class="card">
<div class="card-body p-0">

View File

@ -0,0 +1,513 @@
{% extends "layouts/public_base.html" %}
{% load i18n %}
{% block title %}{% trans "Submit a Complaint" %}{% endblock %}
{% block extra_css %}
<style>
.complaint-form {
max-width: 900px;
margin: 0 auto;
padding: 2rem 0;
}
.form-section {
background: white;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-section h3 {
color: #2c3e50;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #3498db;
}
.alert-box {
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.alert-box.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-box.info {
background-color: #cce5ff;
color: #004085;
border: 1px solid #b8daff;
}
.required-mark {
color: #dc3545;
}
.btn-submit {
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
border: none;
padding: 1rem 2rem;
border-radius: 8px;
color: white;
font-weight: 600;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s;
}
.btn-submit:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
.btn-submit:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-right: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
[dir="rtl"] .spinner {
margin-right: 0;
margin-left: 0.5rem;
}
.category-description {
background-color: #f8f9fa;
border-left: 4px solid #3498db;
padding: 1rem;
margin-top: 0.5rem;
border-radius: 4px;
font-size: 0.95rem;
color: #495057;
}
[dir="rtl"] .category-description {
border-left: none;
border-right: 4px solid #3498db;
}
.description-title {
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="complaint-form">
<div class="alert-box info">
<h4 style="margin-top: 0;">
<i class="fas fa-info-circle"></i> {% trans "About This Form" %}
</h4>
<p style="margin-bottom: 0;">
{% trans "Use this form to submit a complaint about your experience at one of our hospitals. We will review your complaint and get back to you as soon as possible." %}
</p>
</div>
<form id="public_complaint_form" method="post">
{% csrf_token %}
<!-- Contact Information Section -->
<div class="form-section">
<h3><i class="fas fa-user"></i> {% trans "Contact Information" %}</h3>
<div class="form-group">
<label for="id_name">
{% trans "Name" %} <span class="required-mark">*</span>
</label>
<input type="text"
class="form-control"
id="id_name"
name="name"
maxlength="200"
placeholder="{% trans 'Your full name' %}"
required>
<small class="form-text text-muted">
{% trans "Please provide your full name." %}
</small>
</div>
<div class="row mt-3">
<div class="col-md-6">
<div class="form-group">
<label for="id_email">
{% trans "Email Address" %} <span class="required-mark">*</span>
</label>
<input type="email"
class="form-control"
id="id_email"
name="email"
placeholder="your@email.com"
required>
<small class="form-text text-muted">
{% trans "We will use this to contact you about your complaint." %}
</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="id_phone">
{% trans "Phone Number" %} <span class="required-mark">*</span>
</label>
<input type="tel"
class="form-control"
id="id_phone"
name="phone"
maxlength="20"
placeholder="{% trans 'Your phone number' %}"
required>
<small class="form-text text-muted">
{% trans "We may contact you by phone if needed." %}
</small>
</div>
</div>
</div>
</div>
<!-- Complaint Details Section -->
<div class="form-section">
<h3><i class="fas fa-file-alt"></i> {% trans "Complaint Details" %}</h3>
<div class="form-group">
<label for="id_hospital">
{% trans "Hospital" %} <span class="required-mark">*</span>
</label>
<select class="form-control"
id="id_hospital"
name="hospital"
required>
<option value="">{% trans "Select Hospital" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group mt-3">
<label for="id_category">
{% trans "Category" %} <span class="required-mark">*</span>
</label>
<select class="form-control"
id="id_category"
name="category"
required>
<option value="">{% trans "Select Category" %}</option>
</select>
<small class="form-text text-muted">
{% trans "Select the category that best describes your complaint." %}
</small>
<div id="category_description" class="category-description" style="display: none;">
<div class="description-title">{% trans "About this category:" %}</div>
<span id="category_description_text"></span>
</div>
</div>
<div class="form-group mt-3" id="subcategory_container" style="display: none;">
<label for="id_subcategory">
{% trans "Subcategory" %} <span class="required-mark">*</span>
</label>
<select class="form-control"
id="id_subcategory"
name="subcategory"
required>
<option value="">{% trans "Select Subcategory" %}</option>
</select>
<small class="form-text text-muted">
{% trans "Select the specific subcategory within the chosen category." %}
</small>
<div id="subcategory_description" class="category-description" style="display: none;">
<div class="description-title">{% trans "About this subcategory:" %}</div>
<span id="subcategory_description_text"></span>
</div>
</div>
<div class="form-group mt-3">
<label for="id_description">
{% trans "Complaint Description" %} <span class="required-mark">*</span>
</label>
<textarea class="form-control"
id="id_description"
name="description"
rows="6"
placeholder="{% trans 'Please describe your complaint in detail. Include dates, names of staff involved, and any other relevant information.' %}"
required></textarea>
</div>
</div>
<!-- Submit Section -->
<div class="form-section">
<div class="alert-box info">
<p style="margin-bottom: 0;">
<i class="fas fa-clock"></i>
<strong>{% trans "Response Time:" %}</strong>
{% trans "We typically respond to complaints within 24-48 hours depending on severity." %}
</p>
</div>
<div class="text-center">
<button type="submit" class="btn btn-submit btn-lg" id="submit_btn">
<i class="fas fa-paper-plane"></i> {% trans "Submit Complaint" %}
</button>
</div>
</div>
</form>
</div>
<!-- Modal for success -->
<div class="modal fade" id="successModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body text-center" style="padding: 3rem;">
<i class="fas fa-check-circle" style="font-size: 5rem; color: #28a745; margin-bottom: 1.5rem;"></i>
<h3 style="margin-bottom: 1rem;">{% trans "Complaint Submitted Successfully!" %}</h3>
<p class="lead" style="margin-bottom: 1rem;">
{% trans "Your complaint has been received and is being reviewed." %}
</p>
<p>
<strong>{% trans "Reference Number:" %}</strong>
<span id="complaint_reference" style="font-size: 1.5rem; color: #3498db;"></span>
</p>
<p class="text-muted">
{% trans "Please save this reference number for your records." %}
</p>
<a href="{% url 'complaints:public_complaint_submit' %}" class="btn btn-primary">
{% trans "Submit Another Complaint" %}
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(document).ready(function() {
// Get CSRF token
function getCSRFToken() {
// Try to get from cookie first
const cookieValue = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
if (cookieValue) {
return cookieValue;
}
// Fallback to the hidden input
return $('[name="csrfmiddlewaretoken"]').val();
}
// Store all categories data globally for easy access
let allCategories = [];
let currentLanguage = 'en';
// Get description based on current language
function getDescription(category) {
if (currentLanguage === 'ar' && category.description_ar) {
return category.description_ar;
}
return category.description_en || '';
}
// Get name based on current language
function getName(category) {
if (currentLanguage === 'ar' && category.name_ar) {
return category.name_ar;
}
return category.name_en;
}
// Load categories
function loadCategories() {
$.ajax({
url: '{% url "complaints:api_load_categories" %}',
type: 'GET',
success: function(response) {
// Store all categories
allCategories = response.categories;
const categorySelect = $('#id_category');
categorySelect.find('option:not(:first)').remove();
// Only show parent categories (no parent_id)
allCategories.forEach(function(category) {
if (!category.parent_id) {
categorySelect.append($('<option>', {
value: category.id,
text: getName(category)
}));
}
});
},
error: function() {
console.error('Failed to load categories');
}
});
}
// Show category description
function showCategoryDescription(categoryId) {
const category = allCategories.find(c => c.id === categoryId);
const descriptionDiv = $('#category_description');
const descriptionText = $('#category_description_text');
if (category && getDescription(category)) {
descriptionText.text(getDescription(category));
descriptionDiv.show();
} else {
descriptionDiv.hide();
}
}
// Show subcategory description
function showSubcategoryDescription(subcategoryId) {
const subcategory = allCategories.find(c => c.id === subcategoryId);
const descriptionDiv = $('#subcategory_description');
const descriptionText = $('#subcategory_description_text');
if (subcategory && getDescription(subcategory)) {
descriptionText.text(getDescription(subcategory));
descriptionDiv.show();
} else {
descriptionDiv.hide();
}
}
// Load subcategories based on selected category
function loadSubcategories(categoryId) {
if (!categoryId) {
$('#subcategory_container').hide();
$('#subcategory_description').hide();
$('#id_subcategory').find('option:not(:first)').remove();
$('#id_subcategory').prop('required', false);
return;
}
const subcategorySelect = $('#id_subcategory');
subcategorySelect.find('option:not(:first)').remove();
// Filter subcategories for this parent category (match parent_id)
allCategories.forEach(function(category) {
if (category.parent_id == categoryId) {
subcategorySelect.append($('<option>', {
value: category.id,
text: getName(category)
}));
}
});
if (subcategorySelect.find('option').length > 1) {
$('#subcategory_container').show();
$('#id_subcategory').prop('required', true);
} else {
$('#subcategory_container').hide();
$('#subcategory_description').hide();
$('#id_subcategory').prop('required', false);
}
}
// Detect current language from HTML
const htmlLang = document.documentElement.lang;
if (htmlLang === 'ar') {
currentLanguage = 'ar';
}
// Initialize - load categories on page load
loadCategories();
// Handle category change
$('#id_category').on('change', function() {
const categoryId = $(this).val();
loadSubcategories(categoryId);
showCategoryDescription(categoryId);
$('#subcategory_description').hide(); // Hide subcategory description when category changes
});
// Handle subcategory change
$('#id_subcategory').on('change', function() {
const subcategoryId = $(this).val();
showSubcategoryDescription(subcategoryId);
});
// Form submission
$('#public_complaint_form').on('submit', function(e) {
e.preventDefault();
const submitBtn = $('#submit_btn');
const originalText = submitBtn.html();
submitBtn.prop('disabled', true).html(
'<span class="spinner"></span> {% trans "Submitting..." %}'
);
const formData = new FormData(this);
$.ajax({
url: '{% url "complaints:public_complaint_submit" %}',
type: 'POST',
data: formData,
processData: false,
contentType: false,
headers: {
'X-CSRFToken': getCSRFToken()
},
success: function(response) {
if (response.success) {
$('#complaint_reference').text(response.reference_number);
$('#successModal').modal('show');
// Reset form
document.getElementById('public_complaint_form').reset();
} else {
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: response.message || '{% trans "Failed to submit complaint. Please try again." %}'
});
}
},
error: function(xhr) {
let errorMessage = '{% trans "Failed to submit complaint. Please try again." %}';
if (xhr.responseJSON && xhr.responseJSON.errors) {
errorMessage = xhr.responseJSON.errors.join('\n');
}
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: errorMessage
});
},
complete: function() {
submitBtn.prop('disabled', false).html(originalText);
}
});
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,203 @@
{% extends "layouts/public_base.html" %}
{% load i18n %}
{% block title %}{% trans "Complaint Submitted" %}{% endblock %}
{% block extra_css %}
<style>
.success-container {
max-width: 700px;
margin: 4rem auto;
text-align: center;
padding: 3rem;
}
.success-icon {
font-size: 6rem;
color: #28a745;
margin-bottom: 2rem;
animation: scaleIn 0.5s ease-out;
}
@keyframes scaleIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.success-card {
background: white;
border-radius: 12px;
padding: 3rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.reference-number {
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 8px;
font-size: 2rem;
font-weight: bold;
display: inline-block;
margin: 2rem 0;
letter-spacing: 2px;
}
.info-box {
background: #f8f9fa;
border-left: 4px solid #3498db;
padding: 1.5rem;
text-align: left;
margin: 2rem 0;
border-radius: 4px;
}
.info-box h4 {
color: #2c3e50;
margin-top: 0;
}
.info-box ul {
margin-bottom: 0;
padding-left: 1.5rem;
}
.info-box li {
margin-bottom: 0.5rem;
}
.btn-primary-custom {
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
border: none;
padding: 1rem 2.5rem;
border-radius: 8px;
color: white;
font-weight: 600;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s;
display: inline-block;
text-decoration: none;
margin: 0.5rem;
}
.btn-primary-custom:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
}
.btn-secondary-custom {
background: #6c757d;
border: none;
padding: 1rem 2.5rem;
border-radius: 8px;
color: white;
font-weight: 600;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s;
display: inline-block;
text-decoration: none;
margin: 0.5rem;
}
.btn-secondary-custom:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.contact-info {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #dee2e6;
}
.contact-info p {
color: #6c757d;
margin-bottom: 0.5rem;
}
.contact-info strong {
color: #2c3e50;
}
</style>
{% endblock %}
{% block content %}
<div class="success-container">
<div class="success-card">
<div class="success-icon">
<i class="fas fa-check-circle"></i>
</div>
<h1 style="color: #2c3e50; margin-bottom: 1rem;">
{% trans "Complaint Submitted Successfully!" %}
</h1>
<p class="lead" style="color: #6c757d;">
{% trans "Thank you for your feedback. Your complaint has been received and is being reviewed." %}
</p>
<div class="reference-number">
#{{ reference_number }}
</div>
<p style="color: #6c757d;">
<i class="fas fa-info-circle"></i>
{% trans "Please save this reference number for your records. You will need it to track your complaint status." %}
</p>
<div class="info-box">
<h4><i class="fas fa-clock"></i> {% trans "What Happens Next?" %}</h4>
<ul>
<li>{% trans "Your complaint will be reviewed by our team within 24 hours" %}</li>
<li>{% trans "You will receive updates via phone or email based on the contact information provided" %}</li>
<li>{% trans "Our typical response time is 24-48 hours depending on severity" %}</li>
<li>{% trans "You can check the status of your complaint using your reference number" %}</li>
</ul>
</div>
<div class="info-box">
<h4><i class="fas fa-phone"></i> {% trans "Need Immediate Assistance?" %}</h4>
<p style="margin-bottom: 0;">
{% trans "If your complaint is urgent, please contact our Patient Relations department directly at:" %}
</p>
<p style="font-size: 1.2rem; margin: 1rem 0; color: #3498db;">
<i class="fas fa-phone"></i> 920-000-0000
</p>
<p style="margin-bottom: 0; color: #6c757d;">
{% trans "Available Saturday to Thursday, 8:00 AM - 8:00 PM" %}
</p>
</div>
<div style="margin-top: 2rem;">
<a href="{% url 'complaints:public_complaint_submit' %}" class="btn-primary-custom">
<i class="fas fa-plus-circle"></i> {% trans "Submit Another Complaint" %}
</a>
<a href="/" class="btn-secondary-custom">
<i class="fas fa-home"></i> {% trans "Return to Home" %}
</a>
</div>
<div class="contact-info">
<p>
<strong>{% trans "Need Help?" %}</strong>
</p>
<p>
<i class="fas fa-envelope"></i> {% trans "Email:" %}
<a href="mailto:patient.relations@healthcare.sa">patient.relations@healthcare.sa</a>
</p>
<p>
<i class="fas fa-globe"></i> {% trans "Website:" %}
<a href="https://www.healthcare.sa">www.healthcare.sa</a>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,56 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% trans "No Hospital Assigned" %} - PX360{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow border-danger">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="fas fa-exclamation-circle text-danger" style="font-size: 4rem;"></i>
</div>
<h3 class="card-title text-danger mb-3">
{% trans "No Hospital Assigned" %}
</h3>
<p class="card-text text-muted mb-4">
{% trans "Your account does not have a hospital assigned. Please contact your administrator to assign you to a hospital before accessing the system." %}
</p>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>{% trans "Information:" %}</strong>
<ul class="mb-0 mt-2 text-start">
<li>{% trans "Email:" %} {{ request.user.email }}</li>
<li>{% trans "Name:" %} {{ request.user.get_full_name }}</li>
</ul>
</div>
<div class="mt-4">
<a href="{% url 'admin:logout' %}" class="btn btn-outline-danger">
<i class="fas fa-sign-out-alt me-2"></i>
{% trans "Logout" %}
</a>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-question-circle me-2"></i>
{% trans "Need Help?" %}
</h5>
<p class="card-text text-muted small">
{% trans "If you believe this is an error, please contact your PX360 administrator or IT support team for assistance." %}
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,76 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Select Hospital" %} - PX360{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="fas fa-hospital me-2"></i>
{% trans "Select Hospital" %}
</h4>
</div>
<div class="card-body">
<p class="text-muted mb-4">
{% trans "As a PX Admin, you can view and manage data for any hospital. Please select the hospital you want to work with:" %}
</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<div class="list-group mb-4">
{% for hospital in hospitals %}
<label class="list-group-item list-group-item-action">
<input class="form-check-input me-3" type="radio"
name="hospital_id"
value="{{ hospital.id }}"
{% if hospital.id == selected_hospital_id %}checked{% endif %}>
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ hospital.name }}</h5>
<small class="text-muted">
{% if hospital.id == selected_hospital_id %}
<span class="badge bg-success">
{% trans "Selected" %}
</span>
{% endif %}
</small>
</div>
{% if hospital.city %}
<p class="mb-1 text-muted small">
<i class="fas fa-map-marker-alt me-1"></i>
{{ hospital.city }}
{% if hospital.country %}, {{ hospital.country }}{% endif %}
</p>
{% endif %}
</label>
{% empty %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans "No hospitals found in the system." %}
</div>
{% endfor %}
</div>
<div class="d-flex justify-content-between">
<a href="/" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>
{% trans "Back to Dashboard" %}
</a>
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-check me-2"></i>
{% trans "Continue" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,4 +1,5 @@
{% load i18n static%}
{% load i18n %}
{% load hospital_filters %}
<div class="sidebar">
<!-- Brand -->
<div class="sidebar-brand">
@ -10,6 +11,7 @@
<ul class="nav flex-column">
<!-- Command Center -->
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'command_center' %}active{% endif %}"
href="{% url 'analytics:command_center' %}">
<i class="bi bi-speedometer2"></i>
@ -124,6 +126,7 @@
</a>
</li>
<!-- PX Actions -->
<li class="nav-item">
<a class="nav-link {% if 'actions' in request.path %}active{% endif %}"
@ -260,11 +263,7 @@
{% if user.is_px_admin %}
<li class="nav-item">
<a class="nav-link {% if 'config' in request.path %}active{% endif %}"
data-bs-toggle="collapse"
href="#settingsMenu"
role="button"
aria-expanded="{% if 'config' in request.path %}true{% else %}false{% endif %}"
aria-controls="settingsMenu">
href="{% url 'config:dashboard' %}">
<i class="bi bi-gear"></i>
{% trans "Settings" %}
<i class="bi bi-chevron-down ms-auto"></i>
@ -292,4 +291,71 @@
{% endif %}
</ul>
</nav>
<!-- Hospital Display -->
{% if current_hospital %}
<div class="hospital-selector px-3 py-2" style="border-top: 1px solid rgba(255,255,255,0.1);">
{% if is_px_admin %}
<div class="dropdown">
<button class="btn btn-light w-100 d-flex align-items-center text-start" type="button" data-bs-toggle="dropdown">
<i class="bi bi-hospital me-2 text-primary"></i>
<div class="flex-grow-1">
<div class="fw-semibold" style="font-size: 0.9rem;">
{{ current_hospital.name|truncatewords:3 }}
</div>
<div class="text-muted" style="font-size: 0.75rem;">
{% if current_hospital.city %}{{ current_hospital.city }}{% endif %}
</div>
</div>
<i class="bi bi-chevron-down text-muted"></i>
</button>
<ul class="dropdown-menu w-100" style="max-height: 400px; overflow-y: auto;">
<li class="dropdown-header">
<i class="bi bi-building me-2"></i>{% trans "Switch Hospital" %}
</li>
<li><hr class="dropdown-divider"></li>
{% get_all_hospitals as hospitals %}
{% for hospital in hospitals %}
<li>
<form action="{% url 'core:select_hospital' %}" method="post">
{% csrf_token %}
<input type="hidden" name="hospital_id" value="{{ hospital.id }}">
<button type="submit" class="dropdown-item d-flex align-items-center w-100{% if hospital.id == current_hospital.id %} active{% endif %}">
<i class="bi bi-hospital me-2 text-primary"></i>
<div class="text-start flex-grow-1">
<div class="fw-semibold" style="font-size: 0.9rem;">{{ hospital.name }}</div>
<div class="text-muted" style="font-size: 0.75rem;">
{% if hospital.city %}{{ hospital.city }}{% endif %}
</div>
</div>
{% if hospital.id == current_hospital.id %}
<i class="bi bi-check2 text-success"></i>
{% endif %}
</button>
</form>
</li>
{% endfor %}
<li><hr class="dropdown-divider"></li>
<li>
<a href="{% url 'core:select_hospital' %}" class="dropdown-item">
<i class="bi bi-list-ul me-2"></i>{% trans "View All Hospitals" %}
</a>
</li>
</ul>
</div>
{% else %}
<div class="d-flex align-items-center">
<i class="bi bi-hospital me-2 text-primary"></i>
<div>
<div class="fw-semibold" style="font-size: 0.9rem;">
{{ current_hospital.name|truncatewords:3 }}
</div>
<div class="text-muted" style="font-size: 0.75rem;">
{% if current_hospital.city %}{{ current_hospital.city }}{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>

View File

@ -91,9 +91,10 @@
<div class="fw-semibold" style="color: var(--hh-text-dark);">{{ user.get_full_name|default:user.username }}</div>
<small style="color: var(--hh-text-muted);">{{ user.get_role_names.0|default:"User" }}</small>
</div>
{# <div class="avatar avatar-teal">#}
{# {{ user.first_name.0|default:user.username.0|upper }}#}
{# </div>#}
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px;">
{{ user.first_name.0|default:user.username.0|upper }}
</div>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li class="dropdown-header">

View File

@ -0,0 +1,88 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{% block title %}{% trans "PX360 - Patient Experience Management" %}{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.public-header {
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1rem 0;
}
.public-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-top: 2rem;
margin-bottom: 2rem;
}
.public-footer {
background: rgba(255,255,255,0.9);
padding: 2rem 0;
margin-top: 2rem;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Header -->
<header class="public-header">
<div class="container">
<div class="d-flex align-items-center justify-content-between">
<h3 class="mb-0">
<i class="fas fa-heartbeat text-primary"></i> PX360
</h3>
<span class="text-muted">
{% trans "Patient Experience Management" %}
</span>
</div>
</div>
</header>
<!-- Main Content -->
<main>
<div class="container">
{% block content %}{% endblock %}
</div>
</main>
<!-- Footer -->
<footer class="public-footer">
<div class="container text-center">
<p class="mb-0 text-muted">
&copy; {% now "Y" %} PX360. {% trans "All rights reserved." %}
</p>
</div>
</footer>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

1243
uv.lock generated

File diff suppressed because it is too large Load Diff