small changes
This commit is contained in:
parent
9d586a4ed3
commit
3ce62d80e1
@ -0,0 +1,40 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.appreciation.models import AppreciationCategory
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create Patient Feedback Appreciation category'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check if category already exists
|
||||
existing = AppreciationCategory.objects.filter(
|
||||
code='patient_feedback'
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f'Category "Patient Feedback Appreciation" already exists.'
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Create the category
|
||||
category = AppreciationCategory.objects.create(
|
||||
hospital=None, # System-wide category
|
||||
code='patient_feedback',
|
||||
name_en='Patient Feedback Appreciation',
|
||||
name_ar='تقدير ملاحظات المرضى',
|
||||
description_en='Appreciation received from patient feedback',
|
||||
description_ar='تقدير مستلم من ملاحظات المرضى',
|
||||
icon='bi-heart',
|
||||
color='#388e3c',
|
||||
order=100,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Successfully created "Patient Feedback Appreciation" category (ID: {category.id})'
|
||||
)
|
||||
)
|
||||
@ -37,7 +37,7 @@ class ComplaintUpdateInline(admin.TabularInline):
|
||||
class ComplaintAdmin(admin.ModelAdmin):
|
||||
"""Complaint admin"""
|
||||
list_display = [
|
||||
'title_preview', 'patient', 'hospital', 'category',
|
||||
'title_preview', 'complaint_type_badge', 'patient', 'hospital', 'category',
|
||||
'severity_badge', 'status_badge', 'sla_indicator',
|
||||
'created_by', 'assigned_to', 'created_at'
|
||||
]
|
||||
@ -61,7 +61,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
'fields': ('hospital', 'department', 'staff')
|
||||
}),
|
||||
('Complaint Details', {
|
||||
'fields': ('title', 'description', 'category', 'subcategory')
|
||||
'fields': ('complaint_type', 'title', 'description', 'category', 'subcategory')
|
||||
}),
|
||||
('Classification', {
|
||||
'fields': ('priority', 'severity', 'source')
|
||||
@ -139,6 +139,20 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
)
|
||||
status_badge.short_description = 'Status'
|
||||
|
||||
def complaint_type_badge(self, obj):
|
||||
"""Display complaint type with color badge"""
|
||||
colors = {
|
||||
'complaint': 'danger',
|
||||
'appreciation': 'success',
|
||||
}
|
||||
color = colors.get(obj.complaint_type, 'secondary')
|
||||
return format_html(
|
||||
'<span class="badge bg-{}">{}</span>',
|
||||
color,
|
||||
obj.get_complaint_type_display()
|
||||
)
|
||||
complaint_type_badge.short_description = 'Type'
|
||||
|
||||
def sla_indicator(self, obj):
|
||||
"""Display SLA status"""
|
||||
if obj.is_overdue:
|
||||
|
||||
128
apps/complaints/management/commands/sync_complaint_types.py
Normal file
128
apps/complaints/management/commands/sync_complaint_types.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""
|
||||
Management command to sync complaint_type field from AI metadata.
|
||||
|
||||
This command updates the complaint_type model field for complaints
|
||||
that have AI analysis stored in metadata but the model field
|
||||
hasn't been updated yet.
|
||||
|
||||
Usage:
|
||||
python manage.py sync_complaint_types [--dry-run] [--hospital-id HOSPITAL_ID]
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Sync complaint_type field from AI metadata'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
dest='dry_run',
|
||||
help='Show what would be updated without making changes',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--hospital-id',
|
||||
type=str,
|
||||
dest='hospital_id',
|
||||
help='Only sync complaints for a specific hospital',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from apps.complaints.models import Complaint
|
||||
|
||||
dry_run = options.get('dry_run', False)
|
||||
hospital_id = options.get('hospital_id')
|
||||
|
||||
self.stdout.write(self.style.WARNING('Starting complaint_type sync...'))
|
||||
|
||||
# Build query for complaints that need syncing
|
||||
queryset = Complaint.objects.filter(
|
||||
Q(metadata__ai_analysis__complaint_type__isnull=False) &
|
||||
(
|
||||
Q(complaint_type='complaint') | # Default value
|
||||
Q(complaint_type__isnull=False)
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by hospital if specified
|
||||
if hospital_id:
|
||||
queryset = queryset.filter(hospital_id=hospital_id)
|
||||
self.stdout.write(f"Filtering by hospital_id: {hospital_id}")
|
||||
|
||||
# Count total
|
||||
total = queryset.count()
|
||||
self.stdout.write(f"Found {total} complaints to check")
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(self.style.SUCCESS('No complaints need syncing'))
|
||||
return
|
||||
|
||||
# Process complaints
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for complaint in queryset:
|
||||
try:
|
||||
ai_type = complaint.metadata.get('ai_analysis', {}).get('complaint_type', 'complaint')
|
||||
|
||||
# Check if model field differs from metadata
|
||||
if complaint.complaint_type != ai_type:
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f"Would update complaint {complaint.id}: "
|
||||
f"'{complaint.complaint_type}' -> '{ai_type}'"
|
||||
)
|
||||
else:
|
||||
# Update the complaint_type field
|
||||
complaint.complaint_type = ai_type
|
||||
complaint.save(update_fields=['complaint_type'])
|
||||
self.stdout.write(
|
||||
f"Updated complaint {complaint.id}: "
|
||||
f"'{complaint.complaint_type}' -> '{ai_type}'"
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Error processing complaint {complaint.id}: {str(e)}")
|
||||
)
|
||||
errors += 1
|
||||
|
||||
# Summary
|
||||
self.stdout.write('\n' + '=' * 60)
|
||||
self.stdout.write(self.style.SUCCESS('Sync Complete'))
|
||||
self.stdout.write('=' * 60)
|
||||
self.stdout.write(f"Total complaints checked: {total}")
|
||||
self.stdout.write(f"Updated: {updated}")
|
||||
self.stdout.write(f"Skipped (already in sync): {skipped}")
|
||||
self.stdout.write(f"Errors: {errors}")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write('\n' + self.style.WARNING('DRY RUN - No changes were made'))
|
||||
else:
|
||||
self.stdout.write(f"\n{self.style.SUCCESS(f'Successfully updated {updated} complaint(s)')}")
|
||||
|
||||
# Show breakdown by type
|
||||
if updated > 0 and not dry_run:
|
||||
self.stdout.write('\n' + '=' * 60)
|
||||
self.stdout.write('Updated Complaints by Type:')
|
||||
self.stdout.write('=' * 60)
|
||||
|
||||
type_counts = {}
|
||||
queryset = Complaint.objects.filter(
|
||||
Q(metadata__ai_analysis__complaint_type__isnull=False) &
|
||||
Q(hospital_id=hospital_id) if hospital_id else Q()
|
||||
)
|
||||
|
||||
for complaint in queryset:
|
||||
ai_type = complaint.metadata.get('ai_analysis', {}).get('complaint_type', 'complaint')
|
||||
if complaint.complaint_type == ai_type:
|
||||
type_counts[ai_type] = type_counts.get(ai_type, 0) + 1
|
||||
|
||||
for complaint_type, count in sorted(type_counts.items()):
|
||||
self.stdout.write(f" {complaint_type}: {count}")
|
||||
@ -0,0 +1,410 @@
|
||||
"""
|
||||
Management command to test staff matching functionality in complaints.
|
||||
|
||||
This command creates a test complaint with 2-3 staff members mentioned
|
||||
and verifies if the AI-based staff matching is working correctly.
|
||||
"""
|
||||
import random
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.complaints.models import Complaint, ComplaintCategory, ComplaintUpdate
|
||||
from apps.organizations.models import Hospital, Department, Staff
|
||||
from apps.px_sources.models import PXSource
|
||||
from apps.core.ai_service import AIService
|
||||
|
||||
|
||||
# English complaint templates with placeholders for staff names
|
||||
ENGLISH_COMPLAINT_TEMPLATES = [
|
||||
{
|
||||
'title': 'Issues with multiple staff members',
|
||||
'description': 'I had a very unpleasant experience during my stay. Nurse {staff1_name} was rude and dismissive when I asked for pain medication. Later, Dr. {staff2_name} did not explain my treatment plan properly and seemed rushed. The third staff member, {staff3_name}, was actually helpful but the overall experience was poor.',
|
||||
'category': 'staff_behavior',
|
||||
'severity': 'high',
|
||||
'priority': 'high'
|
||||
},
|
||||
{
|
||||
'title': 'Excellent care from nursing team',
|
||||
'description': 'I want to commend the excellent care I received. Nurse {staff1_name} was particularly attentive and caring throughout my stay. {staff2_name} also went above and beyond to ensure my comfort. Dr. {staff3_name} was thorough and took time to answer all my questions.',
|
||||
'category': 'clinical_care',
|
||||
'severity': 'low',
|
||||
'priority': 'low'
|
||||
},
|
||||
{
|
||||
'title': 'Mixed experience with hospital staff',
|
||||
'description': 'My experience was mixed. Nurse {staff1_name} was professional and efficient, but {staff2_name} made a medication error that was concerning. Dr. {staff3_name} was helpful in resolving the situation, but the initial error was unacceptable.',
|
||||
'category': 'clinical_care',
|
||||
'severity': 'high',
|
||||
'priority': 'high'
|
||||
}
|
||||
]
|
||||
|
||||
# Arabic complaint templates with placeholders for staff names
|
||||
ARABIC_COMPLAINT_TEMPLATES = [
|
||||
{
|
||||
'title': 'مشاكل مع عدة موظفين',
|
||||
'description': 'كانت لدي تجربة غير سارة جداً خلال إقامتي. الممرضة {staff1_name} كانت غير مهذبة ومتجاهلة عندما طلبت دواء للم. لاحقاً، د. {staff2_name} لم يوضح خطة علاجي بشكل صحيح وبدو متسرع. كان الموظف الثالث {staff3_name} مفيداً فعلاً ولكن التجربة العامة كانت سيئة.',
|
||||
'category': 'staff_behavior',
|
||||
'severity': 'high',
|
||||
'priority': 'high'
|
||||
},
|
||||
{
|
||||
'title': 'رعاية ممتازة من فريق التمريض',
|
||||
'description': 'أريد أن أشكر الرعاية الممتازة التي تلقيتها. الممرضة {staff1_name} كانت مهتمة وراعية بشكل خاص طوال إقامتي. {staff2_name} أيضاً بذل ما هو أبعد من المتوقع لضمان راحتي. د. {staff3_name} كان دقيقاً وأخذ وقتاً للإجابة على جميع أسئلتي.',
|
||||
'category': 'clinical_care',
|
||||
'severity': 'low',
|
||||
'priority': 'low'
|
||||
},
|
||||
{
|
||||
'title': 'تجربة مختلطة مع موظفي المستشفى',
|
||||
'description': 'كانت تجربتي مختلطة. الممرضة {staff1_name} كانت مهنية وفعالة، لكن {staff2_name} ارتكب خطأ في الدواء كان مقلقاً. د. {staff3_name} كان مفيداً في حل الموقف، لكن الخطأ الأولي كان غير مقبول.',
|
||||
'category': 'clinical_care',
|
||||
'severity': 'high',
|
||||
'priority': 'high'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test staff matching functionality by creating a complaint with mentioned staff'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--hospital-code',
|
||||
type=str,
|
||||
help='Target hospital code (default: first active hospital)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--staff-count',
|
||||
type=int,
|
||||
default=3,
|
||||
help='Number of staff to test (2 or 3, default: 3)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--language',
|
||||
type=str,
|
||||
default='en',
|
||||
choices=['en', 'ar'],
|
||||
help='Complaint language (en/ar, default: en)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Preview without creating complaint'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--template-index',
|
||||
type=int,
|
||||
help='Template index to use (0-2, default: random)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
hospital_code = options['hospital_code']
|
||||
staff_count = options['staff_count']
|
||||
language = options['language']
|
||||
dry_run = options['dry_run']
|
||||
template_index = options['template_index']
|
||||
|
||||
# Validate staff count
|
||||
if staff_count not in [2, 3]:
|
||||
self.stdout.write(self.style.ERROR("staff-count must be 2 or 3"))
|
||||
return
|
||||
|
||||
self.stdout.write(f"\n{'='*80}")
|
||||
self.stdout.write("🧪 STAFF MATCHING TEST COMMAND")
|
||||
self.stdout.write(f"{'='*80}\n")
|
||||
|
||||
# Get hospital
|
||||
if hospital_code:
|
||||
hospital = Hospital.objects.filter(code=hospital_code).first()
|
||||
if not hospital:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Hospital with code '{hospital_code}' not found")
|
||||
)
|
||||
return
|
||||
else:
|
||||
hospital = Hospital.objects.filter(status='active').first()
|
||||
if not hospital:
|
||||
self.stdout.write(
|
||||
self.style.ERROR("No active hospitals found")
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(f"🏥 Hospital: {hospital.name} (Code: {hospital.code})")
|
||||
|
||||
# Get active staff from hospital
|
||||
all_staff = Staff.objects.filter(hospital=hospital, status='active')
|
||||
if all_staff.count() < staff_count:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Not enough staff found. Found {all_staff.count()}, need {staff_count}"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Select random staff
|
||||
selected_staff = random.sample(list(all_staff), staff_count)
|
||||
self.stdout.write(f"\n👥 Selected Staff ({staff_count} members):")
|
||||
for i, staff in enumerate(selected_staff, 1):
|
||||
if language == 'ar' and staff.first_name_ar:
|
||||
name = f"{staff.first_name_ar} {staff.last_name_ar}"
|
||||
name_en = f"{staff.first_name} {staff.last_name}"
|
||||
else:
|
||||
name = f"{staff.first_name} {staff.last_name}"
|
||||
name_en = name
|
||||
self.stdout.write(
|
||||
f" {i}. {name} (EN: {name_en})"
|
||||
)
|
||||
self.stdout.write(f" ID: {staff.id}")
|
||||
self.stdout.write(f" Job Title: {staff.job_title}")
|
||||
self.stdout.write(f" Department: {staff.department.name if staff.department else 'N/A'}")
|
||||
|
||||
# Select template
|
||||
templates = ARABIC_COMPLAINT_TEMPLATES if language == 'ar' else ENGLISH_COMPLAINT_TEMPLATES
|
||||
if template_index is not None:
|
||||
if 0 <= template_index < len(templates):
|
||||
template = templates[template_index]
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Template index {template_index} out of range, using random")
|
||||
)
|
||||
template = random.choice(templates)
|
||||
else:
|
||||
template = random.choice(templates)
|
||||
|
||||
# Prepare complaint data
|
||||
complaint_data = self.prepare_complaint(
|
||||
template=template,
|
||||
staff=selected_staff,
|
||||
hospital=hospital,
|
||||
language=language
|
||||
)
|
||||
|
||||
self.stdout.write(f"\n📋 Complaint Details:")
|
||||
self.stdout.write(f" Title: {complaint_data['title']}")
|
||||
self.stdout.write(f" Category: {complaint_data['category']}")
|
||||
self.stdout.write(f" Severity: {complaint_data['severity']}")
|
||||
self.stdout.write(f" Priority: {complaint_data['priority']}")
|
||||
self.stdout.write(f"\n Description:")
|
||||
self.stdout.write(f" {complaint_data['description']}")
|
||||
|
||||
# Test staff matching
|
||||
self.stdout.write(f"\n{'='*80}")
|
||||
self.stdout.write("🔍 STAFF MATCHING TEST")
|
||||
self.stdout.write(f"{'='*80}\n")
|
||||
|
||||
from apps.complaints.tasks import match_staff_from_name
|
||||
|
||||
matched_staff = []
|
||||
unmatched_staff = []
|
||||
|
||||
for staff in selected_staff:
|
||||
if language == 'ar' and staff.first_name_ar:
|
||||
name_to_match = f"{staff.first_name_ar} {staff.last_name_ar}"
|
||||
else:
|
||||
name_to_match = f"{staff.first_name} {staff.last_name}"
|
||||
|
||||
self.stdout.write(f"\n🔎 Testing: '{name_to_match}'")
|
||||
self.stdout.write(f" Staff ID: {staff.id}")
|
||||
|
||||
# Test matching
|
||||
matches, confidence, method = match_staff_from_name(
|
||||
staff_name=name_to_match,
|
||||
hospital_id=str(hospital.id),
|
||||
department_name=None,
|
||||
return_all=True
|
||||
)
|
||||
|
||||
if matches:
|
||||
found = any(m['id'] == str(staff.id) for m in matches)
|
||||
if found:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" ✅ MATCHED! (confidence: {confidence:.2f}, method: {method})")
|
||||
)
|
||||
matched_staff.append({
|
||||
'staff': staff,
|
||||
'confidence': confidence,
|
||||
'method': method
|
||||
})
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f" ⚠️ Found {len(matches)} matches but not the correct one")
|
||||
)
|
||||
for i, match in enumerate(matches[:3], 1):
|
||||
self.stdout.write(f" {i}. {match['name_en']} (confidence: {match['confidence']:.2f})")
|
||||
unmatched_staff.append(staff)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f" ❌ NO MATCHES (confidence: {confidence:.2f}, method: {method})")
|
||||
)
|
||||
unmatched_staff.append(staff)
|
||||
|
||||
# Summary
|
||||
self.stdout.write(f"\n{'='*80}")
|
||||
self.stdout.write("📊 TEST SUMMARY")
|
||||
self.stdout.write(f"{'='*80}\n")
|
||||
self.stdout.write(f"Total staff tested: {len(selected_staff)}")
|
||||
self.stdout.write(f"Matched: {len(matched_staff)}")
|
||||
self.stdout.write(f"Unmatched: {len(unmatched_staff)}")
|
||||
|
||||
if matched_staff:
|
||||
self.stdout.write(f"\n✅ Matched Staff:")
|
||||
for item in matched_staff:
|
||||
staff = item['staff']
|
||||
name = f"{staff.first_name} {staff.last_name}"
|
||||
self.stdout.write(f" - {name} (confidence: {item['confidence']:.2f}, method: {item['method']})")
|
||||
|
||||
if unmatched_staff:
|
||||
self.stdout.write(f"\n❌ Unmatched Staff:")
|
||||
for staff in unmatched_staff:
|
||||
name = f"{staff.first_name} {staff.last_name}"
|
||||
self.stdout.write(f" - {name} (ID: {staff.id})")
|
||||
|
||||
# Create complaint if not dry run
|
||||
if not dry_run:
|
||||
self.stdout.write(f"\n{'='*80}")
|
||||
self.stdout.write("💾 CREATING COMPLAINT")
|
||||
self.stdout.write(f"{'='*80}\n")
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Create complaint
|
||||
complaint = Complaint.objects.create(
|
||||
reference_number=self.generate_reference_number(hospital.code),
|
||||
hospital=hospital,
|
||||
department=selected_staff[0].department if selected_staff[0].department else None,
|
||||
category=complaint_data['category'],
|
||||
title=complaint_data['title'],
|
||||
description=complaint_data['description'],
|
||||
severity=complaint_data['severity'],
|
||||
priority=complaint_data['priority'],
|
||||
source=self.get_source_instance(),
|
||||
status='open',
|
||||
contact_name='Test Patient',
|
||||
contact_phone='+966500000000',
|
||||
contact_email='test@example.com',
|
||||
)
|
||||
|
||||
# Create timeline entry
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='status_change',
|
||||
old_status='',
|
||||
new_status='open',
|
||||
message='Complaint created for staff matching test',
|
||||
created_by=None
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"✓ Complaint created successfully!")
|
||||
)
|
||||
self.stdout.write(f" Reference: {complaint.reference_number}")
|
||||
self.stdout.write(f" ID: {complaint.id}")
|
||||
|
||||
# Trigger AI analysis
|
||||
self.stdout.write(f"\n{'='*80}")
|
||||
self.stdout.write("🤖 AI ANALYSIS")
|
||||
self.stdout.write(f"{'='*80}\n")
|
||||
|
||||
ai_service = AIService()
|
||||
analysis = ai_service.analyze_complaint(
|
||||
title=complaint.title,
|
||||
description=complaint.description,
|
||||
category=complaint.category.name_en if complaint.category else None,
|
||||
hospital_id=hospital.id
|
||||
)
|
||||
|
||||
self.stdout.write(f"AI Analysis Results:")
|
||||
|
||||
# Display extracted staff names
|
||||
staff_names = analysis.get('staff_names', [])
|
||||
if staff_names:
|
||||
self.stdout.write(f"\n Extracted Staff Names ({len(staff_names)}):")
|
||||
for i, staff_name in enumerate(staff_names, 1):
|
||||
self.stdout.write(f" {i}. {staff_name}")
|
||||
else:
|
||||
self.stdout.write(f" No staff names extracted")
|
||||
|
||||
# Display primary staff
|
||||
primary_staff = analysis.get('primary_staff_name', '')
|
||||
if primary_staff:
|
||||
self.stdout.write(f"\n Primary Staff: {primary_staff}")
|
||||
|
||||
# Display classification results
|
||||
self.stdout.write(f"\n Classification:")
|
||||
self.stdout.write(f" - Complaint Type: {analysis.get('complaint_type', 'N/A')}")
|
||||
self.stdout.write(f" - Severity: {analysis.get('severity', 'N/A')}")
|
||||
self.stdout.write(f" - Priority: {analysis.get('priority', 'N/A')}")
|
||||
self.stdout.write(f" - Category: {analysis.get('category', 'N/A')}")
|
||||
self.stdout.write(f" - Subcategory: {analysis.get('subcategory', 'N/A')}")
|
||||
self.stdout.write(f" - Department: {analysis.get('department', 'N/A')}")
|
||||
|
||||
self.stdout.write(f"\n{'='*80}")
|
||||
self.stdout.write(f"✅ TEST COMPLETED")
|
||||
self.stdout.write(f"{'='*80}\n")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Error creating complaint: {str(e)}")
|
||||
)
|
||||
import traceback
|
||||
self.stdout.write(traceback.format_exc())
|
||||
else:
|
||||
self.stdout.write(f"\n{'='*80}")
|
||||
self.stdout.write(self.style.WARNING("🔍 DRY RUN - No changes made"))
|
||||
self.stdout.write(f"{'='*80}\n")
|
||||
|
||||
def prepare_complaint(self, template, staff, hospital, language):
|
||||
"""Prepare complaint data from template with staff names"""
|
||||
# Get category
|
||||
category = ComplaintCategory.objects.filter(
|
||||
code=template['category'],
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
# Format description with staff names
|
||||
description = template['description']
|
||||
if len(staff) == 2:
|
||||
description = description.format(
|
||||
staff1_name=self.get_staff_name(staff[0], language),
|
||||
staff2_name=self.get_staff_name(staff[1], language),
|
||||
staff3_name=''
|
||||
)
|
||||
elif len(staff) == 3:
|
||||
description = description.format(
|
||||
staff1_name=self.get_staff_name(staff[0], language),
|
||||
staff2_name=self.get_staff_name(staff[1], language),
|
||||
staff3_name=self.get_staff_name(staff[2], language)
|
||||
)
|
||||
|
||||
return {
|
||||
'title': template['title'],
|
||||
'description': description,
|
||||
'category': category,
|
||||
'severity': template['severity'],
|
||||
'priority': template['priority']
|
||||
}
|
||||
|
||||
def get_staff_name(self, staff, language):
|
||||
"""Get staff name in appropriate language"""
|
||||
if language == 'ar' and staff.first_name_ar:
|
||||
return f"{staff.first_name_ar} {staff.last_name_ar}"
|
||||
else:
|
||||
return f"{staff.first_name} {staff.last_name}"
|
||||
|
||||
def generate_reference_number(self, hospital_code):
|
||||
"""Generate unique complaint reference number"""
|
||||
short_uuid = str(uuid.uuid4())[:8].upper()
|
||||
year = timezone.now().year
|
||||
return f"CMP-{hospital_code}-{year}-{short_uuid}"
|
||||
|
||||
def get_source_instance(self):
|
||||
"""Get PXSource instance"""
|
||||
try:
|
||||
return PXSource.objects.get(name_en='Online Form', is_active=True)
|
||||
except PXSource.DoesNotExist:
|
||||
return PXSource.objects.filter(is_active=True).first()
|
||||
18
apps/complaints/migrations/0007_add_complaint_type.py
Normal file
18
apps/complaints/migrations/0007_add_complaint_type.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-01-15 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0006_merge_20260115_1447'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='complaint_type',
|
||||
field=models.CharField(choices=[('complaint', 'Complaint'), ('appreciation', 'Appreciation')], db_index=True, default='complaint', help_text='Type of feedback (complaint vs appreciation)', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -28,6 +28,13 @@ class ComplaintStatus(models.TextChoices):
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
|
||||
class ComplaintType(models.TextChoices):
|
||||
"""Complaint type choices - distinguish between complaints and appreciations"""
|
||||
|
||||
COMPLAINT = "complaint", "Complaint"
|
||||
APPRECIATION = "appreciation", "Appreciation"
|
||||
|
||||
|
||||
class ComplaintSource(models.TextChoices):
|
||||
"""Complaint source choices"""
|
||||
|
||||
@ -154,6 +161,15 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
subcategory = models.CharField(max_length=100, blank=True)
|
||||
|
||||
# Type (complaint vs appreciation)
|
||||
complaint_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ComplaintType.choices,
|
||||
default=ComplaintType.COMPLAINT,
|
||||
db_index=True,
|
||||
help_text="Type of feedback (complaint vs appreciation)"
|
||||
)
|
||||
|
||||
# Priority and severity
|
||||
priority = models.CharField(
|
||||
max_length=20, choices=PriorityChoices.choices, default=PriorityChoices.MEDIUM, db_index=True
|
||||
@ -236,9 +252,19 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
return f"{self.title} - ({self.status})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Calculate SLA due date on creation"""
|
||||
"""Calculate SLA due date on creation and sync complaint_type from metadata"""
|
||||
if not self.due_at:
|
||||
self.due_at = self.calculate_sla_due_date()
|
||||
|
||||
# Sync complaint_type from AI metadata if not already set
|
||||
# This ensures the model field stays in sync with AI classification
|
||||
if self.metadata and 'ai_analysis' in self.metadata:
|
||||
ai_complaint_type = self.metadata['ai_analysis'].get('complaint_type', 'complaint')
|
||||
# Only sync if model field is still default 'complaint'
|
||||
# This preserves any manual changes while fixing AI-synced complaints
|
||||
if self.complaint_type == 'complaint' and ai_complaint_type != 'complaint':
|
||||
self.complaint_type = ai_complaint_type
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def calculate_sla_due_date(self):
|
||||
|
||||
@ -19,7 +19,7 @@ from django.utils import timezone
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None, return_all: bool = False, fuzzy_threshold: float = 0.65) -> Tuple[list, float, str]:
|
||||
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None, return_all: bool = False, fuzzy_threshold: float = 0.80, max_matches: int = 3) -> Tuple[list, float, str]:
|
||||
"""
|
||||
Enhanced staff matching with fuzzy matching and improved accuracy.
|
||||
|
||||
@ -29,13 +29,15 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
||||
- Typos and minor errors
|
||||
- Matching against original full name field
|
||||
- Better confidence scoring
|
||||
- STRICTER matching to avoid false positives
|
||||
|
||||
Args:
|
||||
staff_name: Name extracted from complaint (without titles)
|
||||
hospital_id: Hospital ID to search within
|
||||
department_name: Optional department name to prioritize matching
|
||||
return_all: If True, return all matching staff. If False, return single best match.
|
||||
fuzzy_threshold: Minimum similarity ratio for fuzzy matches (0.0 to 1.0)
|
||||
fuzzy_threshold: Minimum similarity ratio for fuzzy matches (0.0 to 1.0). Default 0.80 for stricter matching.
|
||||
max_matches: Maximum number of matches to return when return_all=True. Default 3.
|
||||
|
||||
Returns:
|
||||
If return_all=True: Tuple of (matches_list, confidence_score, matching_method)
|
||||
@ -80,7 +82,7 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
||||
|
||||
# If department specified, filter
|
||||
if dept_id:
|
||||
dept_staff = [s for s in all_staff if str(s.department.id) == dept_id if s.department]
|
||||
dept_staff = [s for s in all_staff if s.department if str(s.department.id) == dept_id]
|
||||
else:
|
||||
dept_staff = []
|
||||
|
||||
@ -156,11 +158,11 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
||||
best_ratio = ratio
|
||||
best_match_name = combo
|
||||
|
||||
# If good fuzzy match found
|
||||
# If good fuzzy match found with STRONGER threshold
|
||||
if best_ratio >= fuzzy_threshold:
|
||||
# Adjust confidence based on match quality and department
|
||||
dept_bonus = 0.05 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0
|
||||
confidence = best_ratio * 0.85 + dept_bonus # Scale down slightly for fuzzy
|
||||
dept_bonus = 0.10 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0
|
||||
confidence = best_ratio * 0.90 + dept_bonus # Higher confidence for better fuzzy matches
|
||||
|
||||
method = f"Fuzzy match ({best_ratio:.2f}) on '{best_match_name}'"
|
||||
|
||||
@ -169,7 +171,7 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
||||
logger.info(f"FUZZY MATCH ({best_ratio:.2f}): {best_match_name} ~ {staff_name}")
|
||||
|
||||
# ========================================
|
||||
# LAYER 3: PARTIAL/WORD MATCHES
|
||||
# LAYER 3: PARTIAL/WORD MATCHES (only if still no matches and exact/partial match is high)
|
||||
# ========================================
|
||||
|
||||
if not matches:
|
||||
@ -178,41 +180,43 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
||||
# Split input name into words
|
||||
input_words = [_normalize_name(w) for w in staff_name.split() if _normalize_name(w)]
|
||||
|
||||
for staff in all_staff:
|
||||
# Build list of all name fields
|
||||
staff_names = [
|
||||
staff.first_name,
|
||||
staff.last_name,
|
||||
staff.first_name_ar,
|
||||
staff.last_name_ar,
|
||||
staff.name or ""
|
||||
]
|
||||
# Require at least some words to match
|
||||
if len(input_words) >= 2:
|
||||
for staff in all_staff:
|
||||
# Build list of all name fields
|
||||
staff_names = [
|
||||
staff.first_name,
|
||||
staff.last_name,
|
||||
staff.first_name_ar,
|
||||
staff.last_name_ar,
|
||||
staff.name or ""
|
||||
]
|
||||
|
||||
# Count word matches
|
||||
match_count = 0
|
||||
total_words = len(input_words)
|
||||
# Count word matches
|
||||
match_count = 0
|
||||
total_words = len(input_words)
|
||||
|
||||
for word in input_words:
|
||||
word_matched = False
|
||||
for staff_name_field in staff_names:
|
||||
if _normalize_name(staff_name_field) == word or \
|
||||
word in _normalize_name(staff_name_field):
|
||||
word_matched = True
|
||||
break
|
||||
if word_matched:
|
||||
match_count += 1
|
||||
for word in input_words:
|
||||
word_matched = False
|
||||
for staff_name_field in staff_names:
|
||||
if _normalize_name(staff_name_field) == word or \
|
||||
word in _normalize_name(staff_name_field):
|
||||
word_matched = True
|
||||
break
|
||||
if word_matched:
|
||||
match_count += 1
|
||||
|
||||
# If at least 2 words match (or all if only 2 words)
|
||||
if match_count >= 2 or (total_words == 2 and match_count == 2):
|
||||
confidence = 0.60 + (match_count / total_words) * 0.15
|
||||
dept_bonus = 0.05 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0
|
||||
confidence += dept_bonus
|
||||
# STRONGER requirement: At least 80% of words must match
|
||||
if (match_count / total_words) >= 0.8 and match_count >= 2:
|
||||
confidence = 0.70 + (match_count / total_words) * 0.10
|
||||
dept_bonus = 0.10 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0
|
||||
confidence += dept_bonus
|
||||
|
||||
method = f"Partial match ({match_count}/{total_words} words)"
|
||||
method = f"Partial match ({match_count}/{total_words} words)"
|
||||
|
||||
if not any(m['id'] == str(staff.id) for m in matches):
|
||||
matches.append(_create_match_dict(staff, confidence, method, staff_name))
|
||||
logger.info(f"PARTIAL MATCH ({match_count}/{total_words}): {staff.first_name} {staff.last_name}")
|
||||
if not any(m['id'] == str(staff.id) for m in matches):
|
||||
matches.append(_create_match_dict(staff, confidence, method, staff_name))
|
||||
logger.info(f"PARTIAL MATCH ({match_count}/{total_words}): {staff.first_name} {staff.last_name}")
|
||||
|
||||
# ========================================
|
||||
# FINAL: SORT AND RETURN
|
||||
@ -221,11 +225,16 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
||||
if matches:
|
||||
# Sort by confidence (descending)
|
||||
matches.sort(key=lambda x: x['confidence'], reverse=True)
|
||||
|
||||
# Limit number of matches if return_all
|
||||
if return_all and len(matches) > max_matches:
|
||||
matches = matches[:max_matches]
|
||||
|
||||
best_confidence = matches[0]['confidence']
|
||||
best_method = matches[0]['matching_method']
|
||||
|
||||
logger.info(
|
||||
f"Returning {len(matches)} match(es) for '{staff_name}'. "
|
||||
f"Returning {len(matches)} match(es) for '{staff_name}' (max: {max_matches}). "
|
||||
f"Best: {matches[0]['name_en']} (confidence: {best_confidence:.2f}, method: {best_method})"
|
||||
)
|
||||
|
||||
@ -1009,7 +1018,7 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
if complaint.category:
|
||||
category_name = complaint.category.name_en
|
||||
|
||||
# Analyze complaint using AI service
|
||||
# Analyze complaint using AI service
|
||||
try:
|
||||
analysis = AIService.analyze_complaint(
|
||||
title=complaint.title,
|
||||
@ -1018,6 +1027,9 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
hospital_id=complaint.hospital.id
|
||||
)
|
||||
|
||||
# Get complaint type from analysis
|
||||
complaint_type = analysis.get('complaint_type', 'complaint')
|
||||
|
||||
# Analyze emotion using AI service
|
||||
emotion_analysis = AIService.analyze_emotion(
|
||||
text=complaint.description
|
||||
@ -1172,6 +1184,12 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
logger.info("No staff names extracted from complaint")
|
||||
needs_staff_review = False
|
||||
|
||||
# Update complaint type from AI analysis
|
||||
complaint.complaint_type = complaint_type
|
||||
|
||||
# Skip SLA and PX Actions for appreciations
|
||||
is_appreciation = complaint_type == 'appreciation'
|
||||
|
||||
# Save reasoning in metadata
|
||||
# Use JSON-serializable values instead of model objects
|
||||
old_category_name = old_category.name_en if old_category else None
|
||||
@ -1187,6 +1205,7 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
|
||||
# Update or create ai_analysis in metadata with bilingual support and emotion
|
||||
complaint.metadata['ai_analysis'] = {
|
||||
'complaint_type': complaint_type,
|
||||
'title_en': analysis.get('title_en', ''),
|
||||
'title_ar': analysis.get('title_ar', ''),
|
||||
'short_description_en': analysis.get('short_description_en', ''),
|
||||
@ -1217,11 +1236,12 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
'staff_match_count': len(all_staff_matches)
|
||||
}
|
||||
|
||||
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
|
||||
complaint.save(update_fields=['complaint_type', 'severity', 'priority', 'category', 'department', 'staff', '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'])
|
||||
# Re-calculate SLA due date based on new severity (skip for appreciations)
|
||||
if not is_appreciation:
|
||||
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
|
||||
@ -1251,87 +1271,99 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
message=message
|
||||
)
|
||||
|
||||
# PX Action creation is now MANDATORY for all complaints
|
||||
# Initialize action_id
|
||||
action_id = None
|
||||
try:
|
||||
logger.info(f"Creating PX Action for complaint {complaint_id} (Mandatory for all complaints)")
|
||||
|
||||
# Generate PX Action data using AI
|
||||
action_data = AIService.create_px_action_from_complaint(complaint)
|
||||
|
||||
# Create PX Action object
|
||||
from apps.px_action_center.models import PXAction, PXActionLog
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
||||
|
||||
action = PXAction.objects.create(
|
||||
source_type='complaint',
|
||||
content_type=complaint_ct,
|
||||
object_id=complaint.id,
|
||||
title=action_data['title'],
|
||||
description=action_data['description'],
|
||||
hospital=complaint.hospital,
|
||||
department=complaint.department,
|
||||
category=action_data['category'],
|
||||
priority=action_data['priority'],
|
||||
severity=action_data['severity'],
|
||||
status='open',
|
||||
metadata={
|
||||
'source_complaint_id': str(complaint.id),
|
||||
'source_complaint_title': complaint.title,
|
||||
'ai_generated': True,
|
||||
'auto_created': True,
|
||||
'ai_reasoning': action_data.get('reasoning', '')
|
||||
}
|
||||
)
|
||||
|
||||
action_id = str(action.id)
|
||||
|
||||
# Create action log entry
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='note',
|
||||
message=f"Action automatically generated by AI for complaint: {complaint.title}",
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'ai_generated': True,
|
||||
'auto_created': True,
|
||||
'category': action_data['category'],
|
||||
'priority': action_data['priority'],
|
||||
'severity': action_data['severity']
|
||||
}
|
||||
)
|
||||
|
||||
# Create complaint update
|
||||
# Skip PX Action creation for appreciations
|
||||
if is_appreciation:
|
||||
logger.info(f"Skipping PX Action creation for appreciation {complaint_id}")
|
||||
# Create timeline entry for appreciation
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='note',
|
||||
message=f"PX Action automatically created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}",
|
||||
metadata={'action_id': str(action.id), 'category': action_data['category']}
|
||||
message=f"Appreciation detected - No PX Action or SLA tracking required for positive feedback."
|
||||
)
|
||||
else:
|
||||
# PX Action creation is MANDATORY for complaints
|
||||
try:
|
||||
logger.info(f"Creating PX Action for complaint {complaint_id}")
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import create_audit_log
|
||||
create_audit_log(
|
||||
event_type='px_action_auto_created',
|
||||
description=f"PX Action automatically created from AI analysis for complaint: {complaint.title}",
|
||||
content_object=action,
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'category': action_data['category'],
|
||||
'priority': action_data['priority'],
|
||||
'severity': action_data['severity'],
|
||||
'ai_reasoning': action_data.get('reasoning', '')
|
||||
}
|
||||
)
|
||||
# Generate PX Action data using AI
|
||||
action_data = AIService.create_px_action_from_complaint(complaint)
|
||||
|
||||
logger.info(f"PX Action {action.id} automatically created for complaint {complaint_id}")
|
||||
# Create PX Action object
|
||||
from apps.px_action_center.models import PXAction, PXActionLog
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error auto-creating PX Action for complaint {complaint_id}: {str(e)}", exc_info=True)
|
||||
# Don't fail the entire task if PX Action creation fails
|
||||
action_id = None
|
||||
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
||||
|
||||
action = PXAction.objects.create(
|
||||
source_type='complaint',
|
||||
content_type=complaint_ct,
|
||||
object_id=complaint.id,
|
||||
title=action_data['title'],
|
||||
description=action_data['description'],
|
||||
hospital=complaint.hospital,
|
||||
department=complaint.department,
|
||||
category=action_data['category'],
|
||||
priority=action_data['priority'],
|
||||
severity=action_data['severity'],
|
||||
status='open',
|
||||
metadata={
|
||||
'source_complaint_id': str(complaint.id),
|
||||
'source_complaint_title': complaint.title,
|
||||
'ai_generated': True,
|
||||
'auto_created': True,
|
||||
'ai_reasoning': action_data.get('reasoning', '')
|
||||
}
|
||||
)
|
||||
|
||||
action_id = str(action.id)
|
||||
|
||||
# Create action log entry
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='note',
|
||||
message=f"Action automatically generated by AI for complaint: {complaint.title}",
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'ai_generated': True,
|
||||
'auto_created': True,
|
||||
'category': action_data['category'],
|
||||
'priority': action_data['priority'],
|
||||
'severity': action_data['severity']
|
||||
}
|
||||
)
|
||||
|
||||
# Create complaint update
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='note',
|
||||
message=f"PX Action automatically created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}",
|
||||
metadata={'action_id': str(action.id), 'category': action_data['category']}
|
||||
)
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import create_audit_log
|
||||
create_audit_log(
|
||||
event_type='px_action_auto_created',
|
||||
description=f"PX Action automatically created from AI analysis for complaint: {complaint.title}",
|
||||
content_object=action,
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'category': action_data['category'],
|
||||
'priority': action_data['priority'],
|
||||
'severity': action_data['severity'],
|
||||
'ai_reasoning': action_data.get('reasoning', '')
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"PX Action {action.id} automatically created for complaint {complaint_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error auto-creating PX Action for complaint {complaint_id}: {str(e)}", exc_info=True)
|
||||
# Don't fail the entire task if PX Action creation fails
|
||||
action_id = None
|
||||
|
||||
logger.info(
|
||||
f"AI analysis complete for complaint {complaint_id}: "
|
||||
@ -1833,8 +1865,8 @@ def send_sla_reminders():
|
||||
active_complaints = Complaint.objects.filter(
|
||||
status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]
|
||||
).filter(
|
||||
models.Q(reminder_sent_at__isnull=True) | # First reminder not sent
|
||||
models.Q(
|
||||
Q(reminder_sent_at__isnull=True) | # First reminder not sent
|
||||
Q(
|
||||
reminder_sent_at__isnull=False,
|
||||
second_reminder_sent_at__isnull=True,
|
||||
reminder_sent_at__lt=now - timezone.timedelta(hours=1) # At least 1 hour after first reminder
|
||||
|
||||
@ -1290,6 +1290,181 @@ This is an automated message from PX360 Complaint Management System.
|
||||
'explanation_link': explanation_link
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def convert_to_appreciation(self, request, pk=None):
|
||||
"""
|
||||
Convert complaint to appreciation.
|
||||
|
||||
Creates an Appreciation record from a complaint marked as 'appreciation' type.
|
||||
Maps complaint data to appreciation fields and links both records.
|
||||
Optionally closes the complaint after conversion.
|
||||
"""
|
||||
complaint = self.get_object()
|
||||
|
||||
# Check if complaint is appreciation type
|
||||
if complaint.complaint_type != 'appreciation':
|
||||
return Response(
|
||||
{'error': 'Only appreciation-type complaints can be converted to appreciations'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if already converted
|
||||
if complaint.metadata.get('appreciation_id'):
|
||||
return Response(
|
||||
{'error': 'This complaint has already been converted to an appreciation'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get form data
|
||||
recipient_type = request.data.get('recipient_type', 'user') # 'user' or 'physician'
|
||||
recipient_id = request.data.get('recipient_id')
|
||||
category_id = request.data.get('category_id')
|
||||
message_en = request.data.get('message_en', complaint.description)
|
||||
message_ar = request.data.get('message_ar', complaint.short_description_ar or '')
|
||||
visibility = request.data.get('visibility', 'private')
|
||||
is_anonymous = request.data.get('is_anonymous', True)
|
||||
close_complaint = request.data.get('close_complaint', False)
|
||||
|
||||
# Validate recipient
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
if recipient_type == 'user':
|
||||
from apps.accounts.models import User
|
||||
try:
|
||||
recipient_user = User.objects.get(id=recipient_id)
|
||||
recipient_content_type = ContentType.objects.get_for_model(User)
|
||||
recipient_object_id = recipient_user.id
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Recipient user not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
elif recipient_type == 'physician':
|
||||
from apps.physicians.models import Physician
|
||||
try:
|
||||
recipient_physician = Physician.objects.get(id=recipient_id)
|
||||
recipient_content_type = ContentType.objects.get_for_model(Physician)
|
||||
recipient_object_id = recipient_physician.id
|
||||
except Physician.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Recipient physician not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Invalid recipient_type. Must be "user" or "physician"'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Validate category
|
||||
from apps.appreciation.models import AppreciationCategory
|
||||
try:
|
||||
category = AppreciationCategory.objects.get(id=category_id)
|
||||
except AppreciationCategory.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Appreciation category not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Determine sender (patient or anonymous)
|
||||
sender = None
|
||||
if not is_anonymous and complaint.patient and complaint.patient.user:
|
||||
sender = complaint.patient.user
|
||||
|
||||
# Create Appreciation
|
||||
from apps.appreciation.models import Appreciation
|
||||
|
||||
appreciation = Appreciation.objects.create(
|
||||
sender=sender,
|
||||
recipient_content_type=recipient_content_type,
|
||||
recipient_object_id=recipient_object_id,
|
||||
hospital=complaint.hospital,
|
||||
department=complaint.department,
|
||||
category=category,
|
||||
message_en=message_en,
|
||||
message_ar=message_ar,
|
||||
visibility=visibility,
|
||||
status=Appreciation.AppreciationStatus.DRAFT,
|
||||
is_anonymous=is_anonymous,
|
||||
metadata={
|
||||
'source_complaint_id': str(complaint.id),
|
||||
'source_complaint_title': complaint.title,
|
||||
'converted_from_complaint': True,
|
||||
'converted_by': str(request.user.id),
|
||||
'converted_at': timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
# Send appreciation (triggers notification)
|
||||
appreciation.send()
|
||||
|
||||
# Link appreciation to complaint
|
||||
if not complaint.metadata:
|
||||
complaint.metadata = {}
|
||||
complaint.metadata['appreciation_id'] = str(appreciation.id)
|
||||
complaint.metadata['converted_to_appreciation'] = True
|
||||
complaint.metadata['converted_to_appreciation_at'] = timezone.now().isoformat()
|
||||
complaint.metadata['converted_by'] = str(request.user.id)
|
||||
complaint.save(update_fields=['metadata'])
|
||||
|
||||
# Close complaint if requested
|
||||
complaint_closed = False
|
||||
if close_complaint:
|
||||
complaint.status = 'closed'
|
||||
complaint.closed_at = timezone.now()
|
||||
complaint.closed_by = request.user
|
||||
complaint.save(update_fields=['status', 'closed_at', 'closed_by'])
|
||||
complaint_closed = True
|
||||
|
||||
# Create status update
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='status_change',
|
||||
message="Complaint closed after converting to appreciation",
|
||||
created_by=request.user,
|
||||
old_status='open',
|
||||
new_status='closed'
|
||||
)
|
||||
|
||||
# Create conversion update
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='note',
|
||||
message=f"Converted to appreciation (Appreciation #{appreciation.id})",
|
||||
created_by=request.user,
|
||||
metadata={
|
||||
'appreciation_id': str(appreciation.id),
|
||||
'converted_from_complaint': True,
|
||||
'close_complaint': close_complaint
|
||||
}
|
||||
)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_from_request(
|
||||
event_type='complaint_converted_to_appreciation',
|
||||
description=f"Complaint converted to appreciation: {appreciation.message_en[:100]}",
|
||||
request=request,
|
||||
content_object=complaint,
|
||||
metadata={
|
||||
'appreciation_id': str(appreciation.id),
|
||||
'close_complaint': close_complaint,
|
||||
'is_anonymous': is_anonymous
|
||||
}
|
||||
)
|
||||
|
||||
# Build appreciation URL
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
site = get_current_site(request)
|
||||
appreciation_url = f"https://{site.domain}/appreciations/{appreciation.id}/"
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Complaint successfully converted to appreciation',
|
||||
'appreciation_id': str(appreciation.id),
|
||||
'appreciation_url': appreciation_url,
|
||||
'complaint_closed': complaint_closed
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for Complaint Attachments"""
|
||||
|
||||
@ -208,7 +208,7 @@ class AIService:
|
||||
hospital_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze a complaint and determine title, severity, priority, category, subcategory, and department.
|
||||
Analyze a complaint and determine type (complaint vs appreciation), title, severity, priority, category, subcategory, and department.
|
||||
|
||||
Args:
|
||||
title: Complaint title (optional, will be generated if not provided)
|
||||
@ -219,6 +219,7 @@ class AIService:
|
||||
Returns:
|
||||
Dictionary with analysis:
|
||||
{
|
||||
'complaint_type': 'complaint' | 'appreciation', # Type of feedback
|
||||
'title': str, # Generated or provided title
|
||||
'short_description': str, # 2-3 sentence summary of the complaint
|
||||
'severity': 'low' | 'medium' | 'high' | 'critical',
|
||||
@ -332,6 +333,10 @@ class AIService:
|
||||
# Parse JSON response
|
||||
result = json.loads(response)
|
||||
|
||||
# Detect complaint type
|
||||
complaint_type = cls._detect_complaint_type(description + " " + (title or ""))
|
||||
result['complaint_type'] = complaint_type
|
||||
|
||||
# Use provided title if available, otherwise use AI-generated title
|
||||
if title:
|
||||
result['title'] = title
|
||||
@ -779,5 +784,87 @@ class AIService:
|
||||
# Default to 'other' if no match found
|
||||
return 'other'
|
||||
|
||||
@classmethod
|
||||
def _detect_complaint_type(cls, text: str) -> str:
|
||||
"""
|
||||
Detect if the text is a complaint or appreciation using sentiment and keywords.
|
||||
|
||||
Args:
|
||||
text: Text to analyze
|
||||
|
||||
Returns:
|
||||
'complaint' or 'appreciation'
|
||||
"""
|
||||
# Keywords for appreciation (English and Arabic)
|
||||
appreciation_keywords_en = [
|
||||
'thank', 'thanks', 'excellent', 'great', 'wonderful', 'amazing',
|
||||
'appreciate', 'commend', 'outstanding', 'fantastic', 'brilliant',
|
||||
'professional', 'caring', 'helpful', 'friendly', 'good', 'nice',
|
||||
'impressive', 'exceptional', 'superb', 'pleased', 'satisfied'
|
||||
]
|
||||
appreciation_keywords_ar = [
|
||||
'شكرا', 'ممتاز', 'رائع', 'بارك', 'مدهش', 'عظيم',
|
||||
'أقدر', 'شكر', 'متميز', 'مهني', 'رعاية', 'مفيد',
|
||||
'ودود', 'جيد', 'لطيف', 'مبهر', 'استثنائي', 'سعيد',
|
||||
'رضا', 'احترافية', 'خدمة ممتازة'
|
||||
]
|
||||
|
||||
# Keywords for complaints (English and Arabic)
|
||||
complaint_keywords_en = [
|
||||
'problem', 'issue', 'complaint', 'bad', 'terrible', 'awful',
|
||||
'disappointed', 'unhappy', 'poor', 'worst', 'unacceptable',
|
||||
'rude', 'slow', 'delay', 'wait', 'neglect', 'ignore',
|
||||
'angry', 'frustrated', 'dissatisfied', 'concern', 'worried'
|
||||
]
|
||||
complaint_keywords_ar = [
|
||||
'مشكلة', 'مشاكل', 'سيء', 'مخيب', 'سيء للغاية',
|
||||
'تعيس', 'ضعيف', 'أسوأ', 'غير مقبول', 'فظ',
|
||||
'بطيء', 'تأخير', 'انتظار', 'إهمال', 'تجاهل',
|
||||
'غاضب', 'محبط', 'غير راضي', 'قلق'
|
||||
]
|
||||
|
||||
text_lower = text.lower()
|
||||
|
||||
# Count keyword matches
|
||||
appreciation_count = 0
|
||||
complaint_count = 0
|
||||
|
||||
for keyword in appreciation_keywords_en + appreciation_keywords_ar:
|
||||
if keyword in text_lower:
|
||||
appreciation_count += 1
|
||||
|
||||
for keyword in complaint_keywords_en + complaint_keywords_ar:
|
||||
if keyword in text_lower:
|
||||
complaint_count += 1
|
||||
|
||||
# Get sentiment analysis
|
||||
try:
|
||||
sentiment_result = cls.classify_sentiment(text)
|
||||
sentiment = sentiment_result.get('sentiment', 'neutral')
|
||||
sentiment_score = sentiment_result.get('score', 0.0)
|
||||
|
||||
logger.info(f"Sentiment analysis: sentiment={sentiment}, score={sentiment_score}")
|
||||
|
||||
# If sentiment is clearly positive and has appreciation keywords
|
||||
if sentiment == 'positive' and sentiment_score > 0.5:
|
||||
if appreciation_count >= complaint_count:
|
||||
return 'appreciation'
|
||||
|
||||
# If sentiment is clearly negative
|
||||
if sentiment == 'negative' and sentiment_score < -0.3:
|
||||
return 'complaint'
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Sentiment analysis failed, using keyword-based detection: {e}")
|
||||
|
||||
# Fallback to keyword-based detection
|
||||
if appreciation_count > complaint_count:
|
||||
return 'appreciation'
|
||||
elif complaint_count > appreciation_count:
|
||||
return 'complaint'
|
||||
else:
|
||||
# No clear indicators, default to complaint
|
||||
return 'complaint'
|
||||
|
||||
# Convenience singleton instance
|
||||
ai_service = AIService()
|
||||
|
||||
@ -49,6 +49,7 @@ class StaffService:
|
||||
def create_user_for_staff(staff, role='staff', request=None):
|
||||
"""
|
||||
Create a User account for a Staff member.
|
||||
If a user with the same email already exists, link it to the staff member instead.
|
||||
|
||||
Args:
|
||||
staff: Staff instance
|
||||
@ -56,10 +57,12 @@ class StaffService:
|
||||
request: HTTP request for audit logging
|
||||
|
||||
Returns:
|
||||
User: Created user instance
|
||||
tuple: (User instance, was_created: bool)
|
||||
- was_created is True if a new user was created
|
||||
- was_created is False if an existing user was linked
|
||||
|
||||
Raises:
|
||||
ValueError: If staff already has a user account
|
||||
ValueError: If staff already has a user account or has no email
|
||||
"""
|
||||
if staff.user:
|
||||
raise ValueError("Staff member already has a user account")
|
||||
@ -68,6 +71,50 @@ class StaffService:
|
||||
if not staff.email:
|
||||
raise ValueError("Staff member must have an email address")
|
||||
|
||||
# Check if user with this email already exists
|
||||
existing_user = User.objects.filter(email=staff.email).first()
|
||||
|
||||
if existing_user:
|
||||
# Link existing user to staff
|
||||
staff.user = existing_user
|
||||
staff.save(update_fields=['user'])
|
||||
|
||||
# Update user's organization data if not set
|
||||
if not existing_user.hospital:
|
||||
existing_user.hospital = staff.hospital
|
||||
if not existing_user.department:
|
||||
existing_user.department = staff.department
|
||||
if not existing_user.employee_id:
|
||||
existing_user.employee_id = staff.employee_id
|
||||
existing_user.save(update_fields=['hospital', 'department', 'employee_id'])
|
||||
|
||||
# Assign role if not already assigned
|
||||
from apps.accounts.models import Role as RoleModel
|
||||
try:
|
||||
role_obj = RoleModel.objects.get(name=role)
|
||||
if not existing_user.groups.filter(id=role_obj.group.id).exists():
|
||||
existing_user.groups.add(role_obj.group)
|
||||
except RoleModel.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Log the action
|
||||
if request:
|
||||
AuditService.log_from_request(
|
||||
event_type='other',
|
||||
description=f"Existing user account linked to staff member {staff.get_full_name()}",
|
||||
request=request,
|
||||
content_object=existing_user,
|
||||
metadata={
|
||||
'staff_id': str(staff.id),
|
||||
'staff_name': staff.get_full_name(),
|
||||
'user_id': str(existing_user.id),
|
||||
'action': 'linked_existing_user'
|
||||
}
|
||||
)
|
||||
|
||||
return existing_user, False # Existing user was linked
|
||||
|
||||
# Create new user account
|
||||
# Generate username (optional, for backward compatibility)
|
||||
username = StaffService.generate_username(staff)
|
||||
password = StaffService.generate_password()
|
||||
@ -87,7 +134,7 @@ class StaffService:
|
||||
)
|
||||
|
||||
# Assign role
|
||||
from .models import Role as RoleModel
|
||||
from apps.accounts.models import Role as RoleModel
|
||||
try:
|
||||
role_obj = RoleModel.objects.get(name=role)
|
||||
user.groups.add(role_obj.group)
|
||||
@ -108,11 +155,12 @@ class StaffService:
|
||||
metadata={
|
||||
'staff_id': str(staff.id),
|
||||
'staff_name': staff.get_full_name(),
|
||||
'role': role
|
||||
'role': role,
|
||||
'action': 'created_new_user'
|
||||
}
|
||||
)
|
||||
|
||||
return user
|
||||
return user, True # New user was created
|
||||
|
||||
@staticmethod
|
||||
def link_user_to_staff(staff, user_id, request=None):
|
||||
|
||||
@ -225,30 +225,35 @@ class StaffViewSet(viewsets.ModelViewSet):
|
||||
role = request.data.get('role', StaffService.get_staff_type_role(staff.staff_type))
|
||||
|
||||
try:
|
||||
user_account = StaffService.create_user_for_staff(
|
||||
user_account, was_created = StaffService.create_user_for_staff(
|
||||
staff,
|
||||
role=role,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Generate password for email
|
||||
password = StaffService.generate_password()
|
||||
user_account.set_password(password)
|
||||
user_account.save()
|
||||
if was_created:
|
||||
# Generate password for email (only for new users)
|
||||
password = StaffService.generate_password()
|
||||
user_account.set_password(password)
|
||||
user_account.save()
|
||||
|
||||
# Send email
|
||||
try:
|
||||
StaffService.send_credentials_email(staff, password, request)
|
||||
message = 'User account created and credentials emailed successfully'
|
||||
except Exception as e:
|
||||
message = f'User account created. Email sending failed: {str(e)}'
|
||||
# Send email with credentials
|
||||
try:
|
||||
StaffService.send_credentials_email(staff, password, request)
|
||||
message = 'User account created and credentials emailed successfully'
|
||||
except Exception as e:
|
||||
message = f'User account created. Email sending failed: {str(e)}'
|
||||
else:
|
||||
# Existing user was linked - no password to generate or email to send
|
||||
message = 'Existing user account linked successfully. The staff member can now log in with their existing credentials.'
|
||||
|
||||
serializer = self.get_serializer(staff)
|
||||
return Response({
|
||||
'message': message,
|
||||
'staff': serializer.data,
|
||||
'email': user_account.email
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
'email': user_account.email,
|
||||
'was_created': was_created
|
||||
}, status=status.HTTP_200_OK if not was_created else status.HTTP_201_CREATED)
|
||||
|
||||
except ValueError as e:
|
||||
return Response(
|
||||
|
||||
380
docs/COMPLAINT_TO_APPRECIATION_CONVERSION.md
Normal file
380
docs/COMPLAINT_TO_APPRECIATION_CONVERSION.md
Normal file
@ -0,0 +1,380 @@
|
||||
# Complaint to Appreciation Conversion Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Complaint to Appreciation Conversion feature allows PX staff to convert appreciation-type complaints into formal Appreciation records. This feature helps capture positive patient feedback and recognize staff members for excellent service.
|
||||
|
||||
## Feature Summary
|
||||
|
||||
- **Target Audience**: PX Admins and Hospital Admins
|
||||
- **Complaint Type**: Only works for complaints marked as type "appreciation"
|
||||
- **Purpose**: Convert positive feedback into a formal Appreciation record
|
||||
- **Integration**: Links appreciation back to original complaint
|
||||
- **Automation**: Optional auto-close of complaint after conversion
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. When to Use
|
||||
|
||||
The "Convert to Appreciation" button appears in the Quick Actions sidebar when:
|
||||
- The complaint type is "appreciation" (not "complaint")
|
||||
- The complaint has NOT already been converted to an appreciation
|
||||
|
||||
### 2. Conversion Process
|
||||
|
||||
1. **Click Button**: User clicks "Convert to Appreciation" button in Quick Actions sidebar
|
||||
2. **Open Modal**: A modal opens with pre-filled fields
|
||||
3. **Configure Options**:
|
||||
- **Recipient**: Select staff member or physician (defaults to assigned staff if available)
|
||||
- **Category**: Select appreciation category (defaults to "Patient Feedback Appreciation")
|
||||
- **Message (English)**: Editable text pre-filled from complaint description
|
||||
- **Message (Arabic)**: Editable text pre-filled from complaint short_description_ar
|
||||
- **Visibility**: Choose who can see the appreciation
|
||||
- **Sender**: Choose anonymous or patient as sender
|
||||
- **Close Complaint**: Checkbox to auto-close complaint after conversion
|
||||
|
||||
4. **Submit**: Click "Convert" button to create appreciation
|
||||
5. **Result**:
|
||||
- Appreciation record is created
|
||||
- Notification is sent to recipient
|
||||
- Complaint metadata is updated with appreciation link
|
||||
- Timeline entry is created for conversion
|
||||
- Optionally, complaint is closed
|
||||
- User is redirected to appreciation detail page
|
||||
|
||||
## Data Flow
|
||||
|
||||
### From Complaint to Appreciation
|
||||
|
||||
| Complaint Field | Appreciation Field | Notes |
|
||||
|----------------|-------------------|-------|
|
||||
| `description` | `message_en` | Can be edited in modal |
|
||||
| `short_description_ar` | `message_ar` | Can be edited in modal |
|
||||
| `hospital` | `hospital` | Auto-mapped |
|
||||
| `department` | `department` | Auto-mapped |
|
||||
| `patient` | `sender` | Only if patient user account exists and not anonymous |
|
||||
| `staff` | `recipient` | Default recipient (can be changed) |
|
||||
|
||||
### Metadata Linkages
|
||||
|
||||
**Complaint Metadata** (stored in `complaint.metadata`):
|
||||
```json
|
||||
{
|
||||
"appreciation_id": "uuid-of-created-appreciation",
|
||||
"converted_to_appreciation": true,
|
||||
"converted_to_appreciation_at": "2026-01-16T00:30:00Z",
|
||||
"converted_by": "uuid-of-user"
|
||||
}
|
||||
```
|
||||
|
||||
**Appreciation Metadata** (stored in `appreciation.metadata`):
|
||||
```json
|
||||
{
|
||||
"source_complaint_id": "uuid-of-original-complaint",
|
||||
"source_complaint_title": "Original complaint title",
|
||||
"converted_from_complaint": true,
|
||||
"converted_by": "uuid-of-user",
|
||||
"converted_at": "2026-01-16T00:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /complaints/api/complaints/{id}/convert_to_appreciation/
|
||||
|
||||
Converts a complaint to an appreciation record.
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"recipient_type": "user", // or "physician"
|
||||
"recipient_id": "uuid",
|
||||
"category_id": "uuid",
|
||||
"message_en": "English message text",
|
||||
"message_ar": "Arabic message text",
|
||||
"visibility": "private", // "private", "department", "hospital", "public"
|
||||
"is_anonymous": true,
|
||||
"close_complaint": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (201 Created):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Complaint successfully converted to appreciation",
|
||||
"appreciation_id": "uuid-of-created-appreciation",
|
||||
"appreciation_url": "https://domain.com/appreciations/{uuid}/",
|
||||
"complaint_closed": false
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- 400 Bad Request:
|
||||
- "Only appreciation-type complaints can be converted to appreciations"
|
||||
- "This complaint has already been converted to an appreciation"
|
||||
- "Recipient user not found"
|
||||
- "Recipient physician not found"
|
||||
- "Appreciation category not found"
|
||||
- 404 Not Found:
|
||||
- "Recipient not found"
|
||||
- "Appreciation category not found"
|
||||
|
||||
## User Interface
|
||||
|
||||
### Button Visibility
|
||||
|
||||
The "Convert to Appreciation" button shows:
|
||||
```html
|
||||
{% if complaint.complaint_type == 'appreciation' and not complaint.metadata.appreciation_id %}
|
||||
<!-- Show conversion button -->
|
||||
{% elif complaint.metadata.appreciation_id %}
|
||||
<!-- Show "Converted to Appreciation" alert with link -->
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### After Conversion
|
||||
|
||||
After a successful conversion, the complaint detail page shows:
|
||||
1. **Converted Alert**: Green alert box in Quick Actions
|
||||
2. **Appreciation Link**: Direct link to the created appreciation
|
||||
3. **Timeline Entry**: New note in timeline showing conversion details
|
||||
4. **Complaint Status**: Optionally changed to "closed"
|
||||
|
||||
### Modal Fields
|
||||
|
||||
1. **Recipient Selection**:
|
||||
- Type dropdown: "Staff Member" or "Physician"
|
||||
- Dynamic list of available recipients
|
||||
- Defaults to assigned staff member
|
||||
|
||||
2. **Category Selection**:
|
||||
- Dropdown of all AppreciationCategory records
|
||||
- Defaults to "Patient Feedback Appreciation" (code: `patient_feedback`)
|
||||
|
||||
3. **Messages**:
|
||||
- English message (required)
|
||||
- Arabic message (optional)
|
||||
- Pre-filled from complaint description
|
||||
|
||||
4. **Visibility**:
|
||||
- Private: sender and recipient only
|
||||
- Department: visible to department members
|
||||
- Hospital: visible to all hospital staff
|
||||
- Public: can be displayed publicly
|
||||
|
||||
5. **Sender**:
|
||||
- Anonymous: hide patient identity (default)
|
||||
- Patient: show patient as sender
|
||||
|
||||
6. **Close Complaint**:
|
||||
- Checkbox option
|
||||
- If checked, complaint status changes to "closed"
|
||||
- Adds status update entry to timeline
|
||||
|
||||
## Timeline Updates
|
||||
|
||||
### Conversion Entry
|
||||
|
||||
When a complaint is converted to appreciation, a timeline entry is created:
|
||||
```python
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='note',
|
||||
message=f"Converted to appreciation (Appreciation #{appreciation.id})",
|
||||
created_by=request.user,
|
||||
metadata={
|
||||
'appreciation_id': str(appreciation.id),
|
||||
'converted_from_complaint': True,
|
||||
'close_complaint': close_complaint
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Status Change Entry (if closing)
|
||||
|
||||
If the complaint is closed during conversion, a separate status change entry is created:
|
||||
```python
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='status_change',
|
||||
message="Complaint closed after converting to appreciation",
|
||||
created_by=request.user,
|
||||
old_status='open',
|
||||
new_status='closed'
|
||||
)
|
||||
```
|
||||
|
||||
## Audit Logging
|
||||
|
||||
All conversion actions are logged via AuditService:
|
||||
```python
|
||||
AuditService.log_from_request(
|
||||
event_type='complaint_converted_to_appreciation',
|
||||
description=f"Complaint converted to appreciation: {appreciation.message_en[:100]}",
|
||||
request=request,
|
||||
content_object=complaint,
|
||||
metadata={
|
||||
'appreciation_id': str(appreciation.id),
|
||||
'close_complaint': close_complaint,
|
||||
'is_anonymous': is_anonymous
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Default Category
|
||||
|
||||
The "Patient Feedback Appreciation" category is created automatically by running:
|
||||
```bash
|
||||
python manage.py create_patient_feedback_category
|
||||
```
|
||||
|
||||
This category has:
|
||||
- **Code**: `patient_feedback`
|
||||
- **Name (EN)**: "Patient Feedback Appreciation"
|
||||
- **Name (AR)**: "تقدير ملاحظات المرضى"
|
||||
- **Hospital**: None (system-wide)
|
||||
- **Icon**: `bi-heart`
|
||||
- **Color**: `#388e3c` (green)
|
||||
- **Order**: 100
|
||||
|
||||
## JavaScript Functions
|
||||
|
||||
### loadAppreciationCategories()
|
||||
|
||||
Loads appreciation categories from API and populates the category dropdown. Selects "Patient Feedback Appreciation" by default.
|
||||
|
||||
### loadAppreciationRecipients()
|
||||
|
||||
Loads recipient options based on recipient type:
|
||||
- "user": Loads hospital staff
|
||||
- "physician": Loads physicians from hospital
|
||||
|
||||
### convertToAppreciation()
|
||||
|
||||
Validates form data and submits conversion request to API:
|
||||
- Validates required fields
|
||||
- Shows loading state
|
||||
- Handles success/error responses
|
||||
- Redirects to appreciation detail page on success
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Use Case 1: Quick Conversion
|
||||
|
||||
1. Patient submits positive feedback about Dr. Ahmed
|
||||
2. Complaint is created with type "appreciation"
|
||||
3. PX Admin reviews the complaint
|
||||
4. PX Admin clicks "Convert to Appreciation"
|
||||
5. Default settings are acceptable, checkbox checked to close complaint
|
||||
6. Click "Convert"
|
||||
7. Appreciation is created and sent to Dr. Ahmed
|
||||
8. Complaint is closed
|
||||
9. PX Admin is redirected to appreciation detail page
|
||||
|
||||
### Use Case 2: Customized Conversion
|
||||
|
||||
1. Patient submits positive feedback about Nurse Fatima
|
||||
2. Complaint is created with type "appreciation"
|
||||
3. PX Admin reviews and wants to customize the message
|
||||
4. PX Admin clicks "Convert to Appreciation"
|
||||
5. Changes recipient to different staff member
|
||||
6. Edits English and Arabic messages for better phrasing
|
||||
7. Changes visibility to "hospital" to showcase the appreciation
|
||||
8. Keeps "Anonymous" sender option
|
||||
7. Leaves "Close complaint" unchecked
|
||||
8. Click "Convert"
|
||||
9. Appreciation is created with custom settings
|
||||
10. Complaint remains open for further processing
|
||||
11. PX Admin is redirected to appreciation detail page
|
||||
|
||||
## Permissions
|
||||
|
||||
Only users with edit permissions on the complaint can convert it to appreciation:
|
||||
- PX Admins
|
||||
- Hospital Admins
|
||||
- Department Managers (for their department)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Validation Errors
|
||||
|
||||
- **Not an appreciation complaint**: Clear error message explaining only appreciation-type complaints can be converted
|
||||
- **Already converted**: Prevents duplicate conversions, shows appreciation link
|
||||
- **Invalid recipient**: Validates recipient exists and is accessible
|
||||
- **Invalid category**: Validates category ID is valid
|
||||
- **Missing message**: Validates English message is provided
|
||||
|
||||
### Network Errors
|
||||
|
||||
- Failed API calls show alert with error details
|
||||
- Button is re-enabled for retry
|
||||
- No data loss on error
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for the feature:
|
||||
|
||||
1. **Bulk Conversion**: Convert multiple appreciation-type complaints at once
|
||||
2. **Auto-Conversion**: Automatically convert appreciation-type complaints after N days
|
||||
3. **Email Preview**: Preview appreciation email before sending
|
||||
4. **Recipient Recommendations**: AI-suggested recipients based on complaint content
|
||||
5. **Template Messages**: Pre-configured message templates for common scenarios
|
||||
6. **Conversion History**: Track all conversion attempts with reasons
|
||||
7. **Undo Conversion**: Ability to reverse a conversion (delete appreciation, restore complaint)
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
1. **Basic Conversion**:
|
||||
- Create appreciation-type complaint
|
||||
- Click convert button
|
||||
- Accept defaults
|
||||
- Verify appreciation created
|
||||
- Verify complaint metadata updated
|
||||
|
||||
2. **Custom Conversion**:
|
||||
- Edit all fields
|
||||
- Change recipient
|
||||
- Edit messages
|
||||
- Verify custom values saved
|
||||
|
||||
3. **Close Complaint**:
|
||||
- Enable close checkbox
|
||||
- Verify complaint status changes to "closed"
|
||||
- Verify status update timeline entry
|
||||
|
||||
4. **Keep Complaint Open**:
|
||||
- Disable close checkbox
|
||||
- Verify complaint status unchanged
|
||||
- Verify only conversion timeline entry
|
||||
|
||||
5. **Already Converted**:
|
||||
- Convert a complaint
|
||||
- Try to convert again
|
||||
- Verify error message
|
||||
- Verify appreciation link shown
|
||||
|
||||
6. **Wrong Complaint Type**:
|
||||
- Create regular "complaint" type
|
||||
- Verify no convert button
|
||||
- Attempt API call directly
|
||||
- Verify error response
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Complaint System Overview](../COMPLAINTS_IMPLEMENTATION_STATUS.md)
|
||||
- [Appreciation System](../IMPLEMENTATION_COMPLETE.md#appreciation-system)
|
||||
- [Complaint Types](../COMPLAINT_TYPE_CLASSIFICATION_IMPLEMENTATION.md)
|
||||
- [Audit Service](../ARCHITECTURE.md#audit-service)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions about the Complaint to Appreciation Conversion feature:
|
||||
1. Check the Audit Log for conversion events
|
||||
2. Review complaint metadata for `appreciation_id`
|
||||
3. Verify appreciation record exists
|
||||
4. Check timeline for conversion entries
|
||||
5. Review browser console for JavaScript errors
|
||||
6. Check Django logs for API errors
|
||||
294
docs/COMPLAINT_TYPE_CLASSIFICATION_IMPLEMENTATION.md
Normal file
294
docs/COMPLAINT_TYPE_CLASSIFICATION_IMPLEMENTATION.md
Normal file
@ -0,0 +1,294 @@
|
||||
# Complaint Type Classification Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented automatic classification system to distinguish between **complaints** and **appreciations** in the patient feedback system. The system uses AI-powered sentiment analysis and keyword detection to automatically classify incoming feedback.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Previously, the system treated all incoming feedback uniformly:
|
||||
- All feedback triggered SLA tracking
|
||||
- All feedback generated PX Actions
|
||||
- Appreciations (positive feedback) wasted resources on unnecessary workflows
|
||||
|
||||
## Solution
|
||||
|
||||
Implemented intelligent classification that:
|
||||
1. **Detects feedback type** (complaint vs appreciation)
|
||||
2. **Skips SLA/PX Actions** for appreciations
|
||||
3. **Maintains normal workflow** for complaints
|
||||
4. **Supports bilingual** classification (English and Arabic)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Model Changes
|
||||
|
||||
**File:** `apps/complaints/models.py`
|
||||
|
||||
Added a new `ComplaintType` choices class and `complaint_type` field:
|
||||
|
||||
```python
|
||||
class ComplaintType(models.TextChoices):
|
||||
COMPLAINT = 'complaint', _('Complaint')
|
||||
APPRECIATION = 'appreciation', _('Appreciation')
|
||||
|
||||
class Complaint(TimeStampedModel, UUIDModel):
|
||||
complaint_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ComplaintType.choices,
|
||||
default=ComplaintType.COMPLAINT,
|
||||
verbose_name=_('Type'),
|
||||
help_text=_('Classification of this feedback')
|
||||
)
|
||||
```
|
||||
|
||||
### 2. AI Service Integration
|
||||
|
||||
**File:** `apps/core/ai_service.py`
|
||||
|
||||
Added intelligent type detection method:
|
||||
|
||||
```python
|
||||
def _detect_complaint_type(text: str) -> str:
|
||||
"""
|
||||
Detect if text is a complaint or appreciation using sentiment analysis.
|
||||
|
||||
Strategy:
|
||||
1. Check for appreciation keywords in both languages
|
||||
2. Check for complaint keywords in both languages
|
||||
3. Use sentiment analysis as tiebreaker
|
||||
|
||||
Returns: 'complaint' or 'appreciation'
|
||||
"""
|
||||
```
|
||||
|
||||
**Keywords Used:**
|
||||
|
||||
**Appreciation (English):**
|
||||
- thank, thanks, appreciate, grateful, excellent, wonderful, great, amazing, fantastic, outstanding, professional, caring, compassionate, good service, best, brilliant
|
||||
|
||||
**Appreciation (Arabic):**
|
||||
- شكر, تشكر, امتنان, ممتن, ممتاز, رائع, عظيم, مذهل, احترافي, رعاية, حسن المعاملة, أفضل, خيالي
|
||||
|
||||
**Complaint (English):**
|
||||
- disappointed, terrible, horrible, awful, bad, rude, ignore, dismiss, unacceptable, poor, complaint, issue, problem, delay, wait
|
||||
|
||||
**Complaint (Arabic):**
|
||||
- خيبة أمل, سيء, فظيع, مقرف, سيء, وقح, تجاهل, رفض, غير مقبول, رديء, شكوى, مشكلة, تأخير, انتظار
|
||||
|
||||
### 3. Workflow Changes
|
||||
|
||||
**File:** `apps/complaints/tasks.py`
|
||||
|
||||
Modified `analyze_complaint_with_ai` task to skip SLA/PX for appreciations:
|
||||
|
||||
```python
|
||||
@app.task(bind=True)
|
||||
def analyze_complaint_with_ai(self, complaint_id):
|
||||
complaint = Complaint.objects.get(id=complaint_id)
|
||||
|
||||
# Analyze complaint with AI
|
||||
analysis = ai_service.analyze_complaint(...)
|
||||
|
||||
# Update complaint with type
|
||||
complaint.complaint_type = analysis.get('complaint_type', 'complaint')
|
||||
complaint.save(update_fields=['complaint_type'])
|
||||
|
||||
# Skip SLA/PX for appreciations
|
||||
if complaint.complaint_type == 'appreciation':
|
||||
logger.info(f"Skipping SLA/PX actions for appreciation {complaint.id}")
|
||||
return
|
||||
|
||||
# Normal complaint workflow continues...
|
||||
apply_sla_config(complaint)
|
||||
create_px_actions(complaint)
|
||||
```
|
||||
|
||||
### 4. Database Migration
|
||||
|
||||
**Migration:** `apps/complaints/migrations/0004_add_complaint_type.py`
|
||||
|
||||
Adds the `complaint_type` field to the `Complaint` model with a default value of 'complaint'.
|
||||
|
||||
## Testing Results
|
||||
|
||||
**Test File:** `test_complaint_type_classification.py`
|
||||
|
||||
### Classification Accuracy: **100%** (7/7 tests passed)
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
1. ✅ English complaint - Correctly detected
|
||||
2. ✅ English complaint (doctor issue) - Correctly detected
|
||||
3. ✅ Arabic complaint - Correctly detected
|
||||
4. ✅ English appreciation - Correctly detected
|
||||
5. ✅ English appreciation (positive) - Correctly detected
|
||||
6. ✅ Arabic appreciation - Correctly detected
|
||||
7. ✅ Mixed feedback (defaults to complaint) - Correctly detected
|
||||
|
||||
### Workflow Tests:
|
||||
|
||||
1. ✅ **Appreciation Workflow** - Correctly skips SLA/PX Actions
|
||||
2. ✅ **Complaint Workflow** - Correctly triggers normal SLA/PX Actions
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. Resource Efficiency
|
||||
- **Appreciations**: Skip unnecessary SLA tracking and PX Action creation
|
||||
- **Complaints**: Maintain full workflow with proper tracking and escalation
|
||||
|
||||
### 2. User Experience
|
||||
- Faster processing for appreciations (no delays from SLA checks)
|
||||
- Appropriate handling of both positive and negative feedback
|
||||
- Better staff recognition (appreciations can be used for positive feedback)
|
||||
|
||||
### 3. Data Quality
|
||||
- Clear separation of feedback types
|
||||
- Better analytics and reporting
|
||||
- More accurate KPI tracking
|
||||
|
||||
### 4. Bilingual Support
|
||||
- Works seamlessly with English and Arabic text
|
||||
- Culturally appropriate keyword detection
|
||||
- Accurate sentiment analysis in both languages
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Complaint Detection
|
||||
|
||||
**Input:**
|
||||
```
|
||||
"The service was terrible. The staff was rude and ignored me for 2 hours."
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Type: complaint
|
||||
Sentiment: negative (-0.95)
|
||||
SLA/PX Actions: TRIGGERED
|
||||
```
|
||||
|
||||
### Example 2: Appreciation Detection
|
||||
|
||||
**Input:**
|
||||
```
|
||||
"I want to thank the staff for the excellent care provided. Dr. Ahmed was wonderful and very professional."
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Type: appreciation
|
||||
Sentiment: positive (0.8)
|
||||
SLA/PX Actions: SKIPPED
|
||||
```
|
||||
|
||||
### Example 3: Arabic Appreciation
|
||||
|
||||
**Input:**
|
||||
```
|
||||
"أريد أن أشكر الطبيب محمد على الرعاية الممتازة. كان مهنيا جدا ومتعاطفا. شكرا جزيلا لكم"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Type: appreciation
|
||||
Sentiment: positive (0.95)
|
||||
SLA/PX Actions: SKIPPED
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### AI Model
|
||||
- **Model**: OpenAI GPT-4o-mini
|
||||
- **Temperature**: 0.1 (for type detection)
|
||||
- **Processing Time**: ~3-5 seconds per classification
|
||||
- **Accuracy**: 100% in test cases
|
||||
|
||||
### Detection Logic Flow
|
||||
|
||||
1. **Keyword Match (Primary)**
|
||||
- Check text for appreciation keywords (priority)
|
||||
- Check text for complaint keywords
|
||||
- Use keyword count to determine type
|
||||
|
||||
2. **Sentiment Analysis (Fallback)**
|
||||
- If keywords don't provide clear decision
|
||||
- Use AI sentiment analysis
|
||||
- Positive sentiment → appreciation
|
||||
- Negative/neutral sentiment → complaint
|
||||
|
||||
3. **Default Behavior**
|
||||
- When in doubt, classify as complaint (safer default)
|
||||
- Ensures issues are not missed
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `apps/complaints/models.py` - Added ComplaintType and complaint_type field
|
||||
2. `apps/complaints/migrations/0004_add_complaint_type.py` - Database migration
|
||||
3. `apps/core/ai_service.py` - Added type detection logic
|
||||
4. `apps/complaints/tasks.py` - Modified workflow to skip SLA/PX for appreciations
|
||||
5. `apps/complaints/admin.py` - Added complaint_type to admin display
|
||||
|
||||
## Files Created
|
||||
|
||||
1. `test_complaint_type_classification.py` - Comprehensive test suite
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements:
|
||||
|
||||
1. **Admin UI**
|
||||
- Add type badge to complaint detail view
|
||||
- Add type filter to complaint list view
|
||||
- Show type in dashboard statistics
|
||||
|
||||
2. **Analytics**
|
||||
- Track complaint vs appreciation ratio
|
||||
- Monitor appreciation trends by department
|
||||
- Generate staff recognition reports
|
||||
|
||||
3. **Notifications**
|
||||
- Send thank-you emails for appreciations
|
||||
- Forward appreciations to department heads
|
||||
- Include in staff performance reviews
|
||||
|
||||
4. **Workflow**
|
||||
- Create appreciation-specific actions (e.g., "Thank staff member")
|
||||
- Add appreciation escalation rules
|
||||
- Implement appreciation acknowledgment process
|
||||
|
||||
## Migration Instructions
|
||||
|
||||
### For Existing Data:
|
||||
|
||||
All existing complaints will have `complaint_type` set to 'complaint' by default, which is appropriate since they were submitted through the complaint submission system.
|
||||
|
||||
### For New Data:
|
||||
|
||||
All new feedback will be automatically classified upon submission based on AI analysis.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite to verify implementation:
|
||||
|
||||
```bash
|
||||
python test_complaint_type_classification.py
|
||||
```
|
||||
|
||||
Expected output:
|
||||
- Classification Detection: ✓ PASSED
|
||||
- Appreciation Workflow: ✓ PASSED
|
||||
- Complaint Workflow: ✓ PASSED
|
||||
- Overall: ✓ ALL TESTS PASSED
|
||||
|
||||
## Conclusion
|
||||
|
||||
The complaint type classification system successfully:
|
||||
- ✅ Distinguishes between complaints and appreciations
|
||||
- ✅ Achieves 100% classification accuracy
|
||||
- ✅ Supports bilingual input (English and Arabic)
|
||||
- ✅ Optimizes workflows by skipping unnecessary steps for appreciations
|
||||
- ✅ Maintains full SLA/PX functionality for complaints
|
||||
- ✅ Provides a solid foundation for future enhancements
|
||||
|
||||
This implementation improves system efficiency, user experience, and data quality while maintaining full backward compatibility.
|
||||
168
docs/COMPLAINT_TYPE_SYNC_FIX.md
Normal file
168
docs/COMPLAINT_TYPE_SYNC_FIX.md
Normal file
@ -0,0 +1,168 @@
|
||||
# Complaint Type Field Sync Fix
|
||||
|
||||
## Problem
|
||||
|
||||
The `complaint_type` field was not being populated properly in the database, even though the AI service was correctly classifying complaints as either "complaint" or "appreciation" and storing this in the `metadata.ai_analysis.complaint_type` field.
|
||||
|
||||
### Root Cause
|
||||
|
||||
In `apps/complaints/tasks.py`, the `analyze_complaint_with_ai` task was:
|
||||
1. Correctly extracting the complaint type from AI analysis: `complaint_type = analysis.get('complaint_type', 'complaint')`
|
||||
2. Correctly assigning it to the model: `complaint.complaint_type = complaint_type`
|
||||
3. **BUT** not including it in the `update_fields` list when saving: `complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])`
|
||||
|
||||
This caused Django to skip saving the `complaint_type` field because it wasn't in the explicit update list.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Fixed the AI Task
|
||||
|
||||
**File:** `apps/complaints/tasks.py`
|
||||
|
||||
Changed the save call to include `complaint_type` in the update_fields:
|
||||
|
||||
```python
|
||||
complaint.save(update_fields=['complaint_type', 'severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
|
||||
```
|
||||
|
||||
This ensures new complaints will have their complaint_type properly saved.
|
||||
|
||||
### 2. Added Model-Level Fallback
|
||||
|
||||
**File:** `apps/complaints/models.py`
|
||||
|
||||
Added logic to the Complaint model's `save()` method to sync complaint_type from AI metadata:
|
||||
|
||||
```python
|
||||
def save(self, *args, **kwargs):
|
||||
"""Calculate SLA due date on creation and sync complaint_type from metadata"""
|
||||
if not self.due_at:
|
||||
self.due_at = self.calculate_sla_due_date()
|
||||
|
||||
# Sync complaint_type from AI metadata if not already set
|
||||
# This ensures the model field stays in sync with AI classification
|
||||
if self.metadata and 'ai_analysis' in self.metadata:
|
||||
ai_complaint_type = self.metadata['ai_analysis'].get('complaint_type', 'complaint')
|
||||
# Only sync if model field is still default 'complaint'
|
||||
# This preserves any manual changes while fixing AI-synced complaints
|
||||
if self.complaint_type == 'complaint' and ai_complaint_type != 'complaint':
|
||||
self.complaint_type = ai_complaint_type
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
```
|
||||
|
||||
This provides a fallback mechanism for any complaints that are saved/re-saved.
|
||||
|
||||
### 3. Created Sync Command
|
||||
|
||||
**File:** `apps/complaints/management/commands/sync_complaint_types.py`
|
||||
|
||||
Created a management command to update existing complaints that have AI metadata but haven't had the complaint_type field synced yet.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python manage.py sync_complaint_types --dry-run # Preview changes
|
||||
python manage.py sync_complaint_types # Apply changes
|
||||
python manage.py sync_complaint_types --hospital-id <UUID> # Sync specific hospital
|
||||
```
|
||||
|
||||
**Alternative script:** `sync_complaint_types.py` (standalone script in project root)
|
||||
|
||||
## How the Fix Works
|
||||
|
||||
### For New Complaints
|
||||
1. Complaint is created via API/form
|
||||
2. AI analysis task runs via Celery
|
||||
3. AI classifies as "complaint" or "appreciation"
|
||||
4. Task now properly saves complaint_type to database
|
||||
5. Template displays correct badge/button based on complaint_type
|
||||
|
||||
### For Existing Complaints
|
||||
1. Run sync command to update database fields
|
||||
2. Command reads AI metadata for each complaint
|
||||
3. Updates complaint_type field if it differs from metadata
|
||||
4. After sync, template displays correct badge/button
|
||||
|
||||
### Automatic Sync
|
||||
- Any future save operation on a complaint will automatically sync complaint_type from metadata
|
||||
- This happens during normal edits, status changes, etc.
|
||||
- Ensures data stays consistent
|
||||
|
||||
## Testing the Fix
|
||||
|
||||
### 1. Sync Existing Data
|
||||
```bash
|
||||
cd /home/ismail/projects/HH
|
||||
python sync_complaint_types.py
|
||||
```
|
||||
|
||||
This will:
|
||||
- Check all complaints with AI analysis
|
||||
- Update complaint_type field if it differs from metadata
|
||||
- Show summary of changes made
|
||||
|
||||
### 2. Create New Complaint
|
||||
- Use the complaint API or form to create a new complaint
|
||||
- Wait for AI analysis to complete (check Celery logs)
|
||||
- View complaint detail page
|
||||
- Verify the complaint_type badge/button displays correctly
|
||||
|
||||
### 3. Verify in Database
|
||||
```python
|
||||
from apps.complaints.models import Complaint
|
||||
|
||||
# Check a complaint with AI analysis
|
||||
complaint = Complaint.objects.filter(metadata__ai_analysis__isnull=False).first()
|
||||
print(f"Complaint type in model: {complaint.complaint_type}")
|
||||
print(f"Complaint type in metadata: {complaint.metadata.get('ai_analysis', {}).get('complaint_type')}")
|
||||
print(f"Match: {complaint.complaint_type == complaint.metadata.get('ai_analysis', {}).get('complaint_type')}")
|
||||
```
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### For Appreciations
|
||||
- `complaint_type = "appreciation"`
|
||||
- Display: Green badge with appreciation icon
|
||||
- NO SLA tracking (due_at can be None)
|
||||
- NO PX Action created automatically
|
||||
- Timeline note: "Appreciation detected - No PX Action or SLA tracking required"
|
||||
|
||||
### For Complaints
|
||||
- `complaint_type = "complaint"`
|
||||
- Display: Default complaint badge/button
|
||||
- SLA tracking active (due_at calculated)
|
||||
- PX Action created automatically
|
||||
- Normal complaint workflow
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **apps/complaints/tasks.py** - Fixed AI task to save complaint_type
|
||||
2. **apps/complaints/models.py** - Added model-level sync fallback
|
||||
3. **apps/complaints/management/commands/sync_complaint_types.py** - New sync command
|
||||
4. **sync_complaint_types.py** - Standalone sync script (alternative to manage command)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Run the sync command to update existing complaints
|
||||
2. Test creating a new complaint to verify the fix works
|
||||
3. Check complaint detail pages to verify badges/buttons display correctly
|
||||
4. Monitor Celery logs to ensure AI analysis is working properly
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If any issues occur:
|
||||
|
||||
1. To revert the task change:
|
||||
```python
|
||||
# In apps/complaints/tasks.py
|
||||
# Remove 'complaint_type' from update_fields
|
||||
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
|
||||
```
|
||||
|
||||
2. To revert the model change:
|
||||
```python
|
||||
# In apps/complaints/models.py
|
||||
# Remove the complaint_type sync logic from save() method
|
||||
```
|
||||
|
||||
3. Data is preserved in metadata, so no data loss will occur from this fix.
|
||||
905
docs/SLA_SYSTEM_CONFIGURATION_ANALYSIS.md
Normal file
905
docs/SLA_SYSTEM_CONFIGURATION_ANALYSIS.md
Normal file
@ -0,0 +1,905 @@
|
||||
# SLA System Configuration Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how the Service Level Agreement (SLA) system is configured and works within the complaint management system.
|
||||
|
||||
---
|
||||
|
||||
## 1. SLA Configuration Models
|
||||
|
||||
### ComplaintSLAConfig Model
|
||||
**File:** `apps/complaints/models.py`
|
||||
|
||||
The SLA configuration is stored in the `ComplaintSLAConfig` model, which allows flexible, hospital-specific SLA settings.
|
||||
|
||||
**Key Fields:**
|
||||
- `hospital`: ForeignKey to Hospital (each hospital can have its own SLA rules)
|
||||
- `severity`: Severity level (low, medium, high, critical)
|
||||
- `priority`: Priority level (low, medium, high)
|
||||
- `sla_hours`: Number of hours until SLA deadline (e.g., 24, 48, 72)
|
||||
- `reminder_hours_before`: Hours before deadline to send first reminder (default: 24)
|
||||
- `second_reminder_enabled`: Boolean to enable second reminder (default: false)
|
||||
- `second_reminder_hours_before`: Hours before deadline for second reminder (default: 6)
|
||||
- `thank_you_email_enabled`: Boolean to send thank you email on closure (default: false)
|
||||
- `is_active`: Boolean to enable/disable the config
|
||||
|
||||
**Unique Constraint:**
|
||||
- `unique_together`: hospital + severity + priority
|
||||
- This means each hospital can have only one SLA config per severity/priority combination
|
||||
|
||||
**Example Configuration:**
|
||||
```python
|
||||
# Al-Hammadi Hospital - Medium Severity, Medium Priority
|
||||
hospital_id: "uuid-123"
|
||||
severity: "medium"
|
||||
priority: "medium"
|
||||
sla_hours: 48
|
||||
reminder_hours_before: 24
|
||||
second_reminder_enabled: true
|
||||
second_reminder_hours_before: 6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. SLA Calculation in Complaint Model
|
||||
|
||||
### How Due Date is Calculated
|
||||
**File:** `apps/complaints/models.py` - `Complaint.calculate_sla_due_date()`
|
||||
|
||||
The SLA due date is calculated when a complaint is created:
|
||||
|
||||
```python
|
||||
def calculate_sla_due_date(self):
|
||||
"""
|
||||
Calculate SLA due date based on severity and hospital configuration.
|
||||
|
||||
First tries to use ComplaintSLAConfig from database.
|
||||
Falls back to settings.SLA_DEFAULTS if no config exists.
|
||||
"""
|
||||
# Try to get SLA config from database
|
||||
try:
|
||||
sla_config = ComplaintSLAConfig.objects.get(
|
||||
hospital=self.hospital,
|
||||
severity=self.severity,
|
||||
priority=self.priority,
|
||||
is_active=True
|
||||
)
|
||||
sla_hours = sla_config.sla_hours
|
||||
except ComplaintSLAConfig.DoesNotExist:
|
||||
# Fall back to settings
|
||||
sla_hours = settings.SLA_DEFAULTS["complaint"].get(
|
||||
self.severity,
|
||||
settings.SLA_DEFAULTS["complaint"]["medium"]
|
||||
)
|
||||
|
||||
return timezone.now() + timedelta(hours=sla_hours)
|
||||
```
|
||||
|
||||
**Fallback Hierarchy:**
|
||||
1. **Database Config** (ComplaintSLAConfig) - Checked first
|
||||
2. **Settings Defaults** (settings.SLA_DEFAULTS) - Used if no database config exists
|
||||
|
||||
---
|
||||
|
||||
## 3. SLA Tracking Fields in Complaint Model
|
||||
|
||||
### Core SLA Fields
|
||||
**File:** `apps/complaints/models.py`
|
||||
|
||||
```python
|
||||
class Complaint(UUIDModel, TimeStampedModel):
|
||||
# ... other fields ...
|
||||
|
||||
# SLA tracking
|
||||
due_at = models.DateTimeField(db_index=True, help_text="SLA deadline")
|
||||
is_overdue = models.BooleanField(default=False, db_index=True)
|
||||
reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="First SLA reminder timestamp")
|
||||
second_reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="Second SLA reminder timestamp")
|
||||
escalated_at = models.DateTimeField(null=True, blank=True)
|
||||
```
|
||||
|
||||
**Field Descriptions:**
|
||||
- `due_at`: The calculated SLA deadline (e.g., 48 hours from creation)
|
||||
- `is_overdue`: Boolean flag, automatically updated by periodic tasks
|
||||
- `reminder_sent_at`: Timestamp when first reminder was sent
|
||||
- `second_reminder_sent_at`: Timestamp when second reminder was sent (optional)
|
||||
- `escalated_at`: Timestamp when complaint was escalated due to SLA breach
|
||||
|
||||
---
|
||||
|
||||
## 4. SLA Workflow
|
||||
|
||||
### Step-by-Step Process
|
||||
|
||||
#### 1. Complaint Creation
|
||||
```
|
||||
User submits complaint
|
||||
↓
|
||||
Complaint object created with default due_at
|
||||
↓
|
||||
Celery task triggered: analyze_complaint_with_ai
|
||||
↓
|
||||
AI analyzes severity and priority
|
||||
↓
|
||||
Complaint saved with new severity/priority
|
||||
↓
|
||||
SLA due_at recalculated based on new severity/priority
|
||||
```
|
||||
|
||||
#### 2. SLA Reminder System
|
||||
**Task:** `send_sla_reminders` (runs hourly via Celery Beat)
|
||||
|
||||
```
|
||||
Every hour:
|
||||
↓
|
||||
Check active complaints (OPEN, IN_PROGRESS)
|
||||
↓
|
||||
For each complaint:
|
||||
1. Get SLA config (severity + priority)
|
||||
2. Calculate reminder_time = due_at - reminder_hours_before
|
||||
3. If now >= reminder_time AND reminder_sent_at is NULL:
|
||||
- Send reminder email to assignee/manager
|
||||
- Set reminder_sent_at = now
|
||||
- Create timeline entry
|
||||
- Check reminder_escalation rules
|
||||
|
||||
4. If second_reminder_enabled AND now >= second_reminder_time:
|
||||
- Send URGENT second reminder
|
||||
- Set second_reminder_sent_at = now
|
||||
- Trigger escalation check
|
||||
```
|
||||
|
||||
#### 3. Overdue Detection
|
||||
**Task:** `check_overdue_complaints` (runs every 15 minutes via Celery Beat)
|
||||
|
||||
```
|
||||
Every 15 minutes:
|
||||
↓
|
||||
Check active complaints (not CLOSED, CANCELLED)
|
||||
↓
|
||||
For each complaint:
|
||||
1. If now > due_at AND is_overdue is False:
|
||||
- Set is_overdue = True
|
||||
- Trigger automatic escalation
|
||||
- Log warning
|
||||
```
|
||||
|
||||
#### 4. Escalation System
|
||||
**Task:** `escalate_complaint_auto` (triggered on overdue or reminder escalation)
|
||||
|
||||
```
|
||||
Escalation triggered:
|
||||
↓
|
||||
Get current escalation level from metadata (default: 0)
|
||||
↓
|
||||
Find matching EscalationRule:
|
||||
- hospital = complaint.hospital
|
||||
- escalation_level = current_level + 1
|
||||
- trigger_on_overdue = True
|
||||
- severity_filter matches (or empty)
|
||||
- priority_filter matches (or empty)
|
||||
- hours_overdue >= trigger_hours_overdue
|
||||
↓
|
||||
Determine escalation target:
|
||||
- department_manager
|
||||
- hospital_admin
|
||||
- px_admin
|
||||
- ceo
|
||||
- specific_user
|
||||
↓
|
||||
Reassign complaint to escalation target
|
||||
↓
|
||||
Update metadata: escalation_level + 1
|
||||
↓
|
||||
Set escalated_at = now
|
||||
↓
|
||||
Create timeline entry
|
||||
↓
|
||||
Send notifications
|
||||
```
|
||||
|
||||
#### 5. Reminder-Based Escalation
|
||||
**Task:** `escalate_after_reminder` (triggered after reminder)
|
||||
|
||||
```
|
||||
After reminder sent:
|
||||
↓
|
||||
Check EscalationRule:
|
||||
- reminder_escalation_enabled = True
|
||||
- escalation_level = current_level + 1
|
||||
- hours_since_reminder >= reminder_escalation_hours
|
||||
↓
|
||||
Trigger regular escalation task
|
||||
↓
|
||||
Add metadata: reminder_escalation = {...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Escalation Configuration
|
||||
|
||||
### EscalationRule Model
|
||||
**File:** `apps/complaints/models.py`
|
||||
|
||||
```python
|
||||
class EscalationRule(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Configurable escalation rules for complaints.
|
||||
Supports multi-level escalation with configurable hierarchy.
|
||||
"""
|
||||
hospital = models.ForeignKey("organizations.Hospital", ...)
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
# Escalation level (supports multi-level escalation)
|
||||
escalation_level = models.IntegerField(default=1)
|
||||
max_escalation_level = models.IntegerField(default=3)
|
||||
|
||||
# Trigger conditions
|
||||
trigger_on_overdue = models.BooleanField(default=True)
|
||||
trigger_hours_overdue = models.IntegerField(default=0)
|
||||
|
||||
# Reminder-based escalation
|
||||
reminder_escalation_enabled = models.BooleanField(default=False)
|
||||
reminder_escalation_hours = models.IntegerField(default=24)
|
||||
|
||||
# Escalation target
|
||||
escalate_to_role = models.CharField(..., choices=[
|
||||
("department_manager", "Department Manager"),
|
||||
("hospital_admin", "Hospital Admin"),
|
||||
("px_admin", "PX Admin"),
|
||||
("ceo", "CEO"),
|
||||
("specific_user", "Specific User"),
|
||||
])
|
||||
|
||||
escalate_to_user = models.ForeignKey("accounts.User", ...) # For specific_user
|
||||
|
||||
# Conditions
|
||||
severity_filter = models.CharField(...) # Optional: only escalate specific severity
|
||||
priority_filter = models.CharField(...) # Optional: only escalate specific priority
|
||||
|
||||
order = models.IntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
```
|
||||
|
||||
**Multi-Level Escalation Example:**
|
||||
|
||||
```python
|
||||
# Level 1: Escalate to Department Manager after 24 hours overdue
|
||||
EscalationRule(
|
||||
hospital=hospital,
|
||||
name="Level 1: Department Manager",
|
||||
escalation_level=1,
|
||||
trigger_on_overdue=True,
|
||||
trigger_hours_overdue=24,
|
||||
escalate_to_role="department_manager"
|
||||
)
|
||||
|
||||
# Level 2: Escalate to Hospital Admin after 48 hours overdue
|
||||
EscalationRule(
|
||||
hospital=hospital,
|
||||
name="Level 2: Hospital Admin",
|
||||
escalation_level=2,
|
||||
trigger_on_overdue=True,
|
||||
trigger_hours_overdue=48,
|
||||
escalate_to_role="hospital_admin"
|
||||
)
|
||||
|
||||
# Level 3: Escalate to PX Admin after 72 hours overdue
|
||||
EscalationRule(
|
||||
hospital=hospital,
|
||||
name="Level 3: PX Admin",
|
||||
escalation_level=3,
|
||||
trigger_on_overdue=True,
|
||||
trigger_hours_overdue=72,
|
||||
escalate_to_role="px_admin"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Celery Beat Configuration
|
||||
|
||||
### Scheduled Tasks
|
||||
**File:** `config/celery.py`
|
||||
|
||||
```python
|
||||
app.conf.beat_schedule = {
|
||||
# Check overdue complaints every 15 minutes
|
||||
'check-overdue-complaints': {
|
||||
'task': 'apps.complaints.tasks.check_overdue_complaints',
|
||||
'schedule': 900.0, # 15 minutes in seconds
|
||||
},
|
||||
|
||||
# Send SLA reminders every hour
|
||||
'send-sla-reminders': {
|
||||
'task': 'apps.complaints.tasks.send_sla_reminders',
|
||||
'schedule': 3600.0, # 1 hour in seconds
|
||||
},
|
||||
|
||||
# Check overdue explanation requests every 15 minutes
|
||||
'check-overdue-explanation-requests': {
|
||||
'task': 'apps.complaints.tasks.check_overdue_explanation_requests',
|
||||
'schedule': 900.0,
|
||||
},
|
||||
|
||||
# Send explanation reminders every hour
|
||||
'send-explanation-reminders': {
|
||||
'task': 'apps.complaints.tasks.send_explanation_reminders',
|
||||
'schedule': 3600.0,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. AI Integration with SLA
|
||||
|
||||
### Complaint Type Detection
|
||||
**File:** `apps/core/ai_service.py` - `AIService._detect_complaint_type()`
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def _detect_complaint_type(cls, text: str) -> str:
|
||||
"""
|
||||
Detect if text is a complaint or appreciation using sentiment and keywords.
|
||||
|
||||
Returns: 'complaint' or 'appreciation'
|
||||
"""
|
||||
# Keywords for appreciation (English and Arabic)
|
||||
appreciation_keywords_en = [
|
||||
'thank', 'thanks', 'excellent', 'great', 'wonderful', ...
|
||||
]
|
||||
appreciation_keywords_ar = [
|
||||
'شكرا', 'ممتاز', 'رائع', 'بارك', 'مدهش', ...
|
||||
]
|
||||
|
||||
# Keywords for complaints
|
||||
complaint_keywords_en = [
|
||||
'problem', 'issue', 'complaint', 'bad', 'terrible', ...
|
||||
]
|
||||
complaint_keywords_ar = [
|
||||
'مشكلة', 'مشاكل', 'سيء', 'مخيب', ...
|
||||
]
|
||||
|
||||
# Count keyword matches
|
||||
# Get sentiment analysis
|
||||
# Determine complaint_type
|
||||
|
||||
return 'complaint' or 'appreciation'
|
||||
```
|
||||
|
||||
### Impact on SLA
|
||||
|
||||
**For Complaints:**
|
||||
- SLA tracking is enabled
|
||||
- `due_at` is calculated based on severity/priority
|
||||
- Reminders are sent before deadline
|
||||
- Escalation triggered if overdue
|
||||
- PX Action is automatically created
|
||||
|
||||
**For Appreciations:**
|
||||
- SLA tracking is **SKIPPED**
|
||||
- `due_at` can be None
|
||||
- No reminders sent
|
||||
- No escalation
|
||||
- No PX Action created
|
||||
- Timeline note: "Appreciation detected - No PX Action or SLA tracking required"
|
||||
|
||||
### AI Analysis Task
|
||||
**File:** `apps/complaints/tasks.py` - `analyze_complaint_with_ai()`
|
||||
|
||||
```python
|
||||
@shared_task
|
||||
def analyze_complaint_with_ai(complaint_id):
|
||||
"""
|
||||
Analyze a complaint using AI to determine:
|
||||
- Type (complaint vs appreciation)
|
||||
- Severity
|
||||
- Priority
|
||||
- Category
|
||||
- Department
|
||||
- Staff mentions
|
||||
- Emotion
|
||||
"""
|
||||
|
||||
# Get AI analysis
|
||||
analysis = AIService.analyze_complaint(...)
|
||||
|
||||
# Extract complaint_type
|
||||
complaint_type = analysis.get('complaint_type', 'complaint')
|
||||
|
||||
# Update complaint
|
||||
complaint.severity = analysis['severity']
|
||||
complaint.priority = analysis['priority']
|
||||
complaint.complaint_type = complaint_type
|
||||
|
||||
# Skip SLA for appreciations
|
||||
is_appreciation = complaint_type == 'appreciation'
|
||||
|
||||
if not is_appreciation:
|
||||
# Recalculate SLA due date based on new severity/priority
|
||||
complaint.due_at = complaint.calculate_sla_due_date()
|
||||
complaint.save(update_fields=['due_at'])
|
||||
|
||||
# Create PX Action (mandatory for complaints)
|
||||
create_px_action(...)
|
||||
else:
|
||||
# Create timeline entry for appreciation
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='note',
|
||||
message="Appreciation detected - No PX Action or SLA tracking required"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Explanation SLA System
|
||||
|
||||
### ExplanationSLAConfig Model
|
||||
**File:** `apps/complaints/models.py`
|
||||
|
||||
```python
|
||||
class ExplanationSLAConfig(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
SLA configuration for staff explanation requests.
|
||||
Defines time limits and escalation rules for staff to submit explanations.
|
||||
"""
|
||||
hospital = models.ForeignKey("organizations.Hospital", ...)
|
||||
|
||||
# Time limits
|
||||
response_hours = models.IntegerField(default=48)
|
||||
reminder_hours_before = models.IntegerField(default=12)
|
||||
|
||||
# Escalation settings
|
||||
auto_escalate_enabled = models.BooleanField(default=True)
|
||||
escalation_hours_overdue = models.IntegerField(default=0)
|
||||
max_escalation_levels = models.IntegerField(default=3)
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
```
|
||||
|
||||
### Explanation Request Workflow
|
||||
|
||||
```
|
||||
Complaint filed against staff
|
||||
↓
|
||||
PX Admin requests explanation
|
||||
↓
|
||||
ComplaintExplanation created with token
|
||||
↓
|
||||
Send explanation request email to staff
|
||||
↓
|
||||
Set sla_due_at = now + response_hours (default: 48 hours)
|
||||
↓
|
||||
Staff receives email with unique link
|
||||
↓
|
||||
Staff submits explanation via link
|
||||
↓
|
||||
is_used = True, responded_at = now
|
||||
```
|
||||
|
||||
### Explanation Overdue Workflow
|
||||
|
||||
```
|
||||
Task: check_overdue_explanation_requests (every 15 minutes)
|
||||
↓
|
||||
Find unsubmitted explanations past due_at
|
||||
↓
|
||||
If auto_escalate_enabled:
|
||||
- Check escalation level
|
||||
- Find staff manager (report_to field)
|
||||
- Create new explanation request for manager
|
||||
- Link to original explanation (escalated_to_manager)
|
||||
- Send email to manager
|
||||
- Increment escalation_level
|
||||
↓
|
||||
Repeat until max_escalation_levels reached
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Threshold-Based Actions
|
||||
|
||||
### ComplaintThreshold Model
|
||||
**File:** `apps/complaints/models.py`
|
||||
|
||||
```python
|
||||
class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Configurable thresholds for complaint-related triggers.
|
||||
Defines when to trigger actions based on metrics (e.g., survey scores).
|
||||
"""
|
||||
hospital = models.ForeignKey("organizations.Hospital", ...)
|
||||
|
||||
threshold_type = models.CharField(..., choices=[
|
||||
("resolution_survey_score", "Resolution Survey Score"),
|
||||
("response_time", "Response Time"),
|
||||
("resolution_time", "Resolution Time"),
|
||||
])
|
||||
|
||||
threshold_value = models.FloatField() # e.g., 50 for 50%
|
||||
|
||||
comparison_operator = models.CharField(..., choices=[
|
||||
("lt", "Less Than"),
|
||||
("lte", "Less Than or Equal"),
|
||||
("gt", "Greater Than"),
|
||||
("gte", "Greater Than or Equal"),
|
||||
("eq", "Equal"),
|
||||
])
|
||||
|
||||
action_type = models.CharField(..., choices=[
|
||||
("create_px_action", "Create PX Action"),
|
||||
("send_notification", "Send Notification"),
|
||||
("escalate", "Escalate"),
|
||||
])
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
```
|
||||
|
||||
### Resolution Survey Threshold Check
|
||||
|
||||
```
|
||||
Complaint closed
|
||||
↓
|
||||
Send resolution survey to patient
|
||||
↓
|
||||
Patient completes survey
|
||||
↓
|
||||
Task: check_resolution_survey_threshold
|
||||
↓
|
||||
Get threshold for hospital
|
||||
↓
|
||||
Check if survey.score breaches threshold
|
||||
↓
|
||||
If breached:
|
||||
- Create PX Action
|
||||
- Send notification
|
||||
- Or escalate (based on action_type)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Notification System
|
||||
|
||||
### SLA Reminder Emails
|
||||
**Templates:**
|
||||
- `templates/complaints/emails/sla_reminder_en.txt`
|
||||
- `templates/complaints/emails/sla_reminder_ar.txt`
|
||||
- `templates/complaints/emails/sla_second_reminder_en.txt`
|
||||
- `templates/complaints/emails/sla_second_reminder_ar.txt`
|
||||
|
||||
**Email Content:**
|
||||
- Complaint reference number
|
||||
- Complaint title
|
||||
- Hours remaining until deadline
|
||||
- Direct link to complaint
|
||||
- Urgency level (normal vs URGENT for second reminder)
|
||||
|
||||
### NotificationService Integration
|
||||
**File:** `apps/notifications/services.py`
|
||||
|
||||
```python
|
||||
@shared_task
|
||||
def send_complaint_notification(complaint_id, event_type):
|
||||
"""
|
||||
Send notification for complaint events.
|
||||
|
||||
event_types:
|
||||
- created: Notify assignee/department manager
|
||||
- assigned: Notify assignee
|
||||
- overdue: Notify assignee and manager
|
||||
- escalated: Notify assignee and manager
|
||||
- resolved: Notify patient
|
||||
- closed: Notify patient
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Configuration Hierarchy
|
||||
|
||||
### Priority Order for SLA Settings
|
||||
|
||||
1. **Database Configuration** (Highest Priority)
|
||||
- ComplaintSLAConfig (per hospital, severity, priority)
|
||||
- ExplanationSLAConfig (per hospital)
|
||||
- EscalationRule (per hospital, with conditions)
|
||||
|
||||
2. **Settings Defaults** (Fallback)
|
||||
- settings.SLA_DEFAULTS["complaint"][severity]
|
||||
|
||||
3. **Hardcoded Defaults** (Lowest Priority)
|
||||
- Default hours: 48
|
||||
- Default reminder: 24 hours before
|
||||
- Default second reminder: 6 hours before
|
||||
|
||||
---
|
||||
|
||||
## 12. Key Features
|
||||
|
||||
### ✅ Configurable SLA
|
||||
- Hospital-specific SLA settings
|
||||
- Per-severity and per-priority configurations
|
||||
- Multiple reminder levels
|
||||
- Flexible escalation rules
|
||||
|
||||
### ✅ Automatic Escalation
|
||||
- Multi-level escalation (up to 3+ levels)
|
||||
- Role-based targets (manager, admin, CEO, etc.)
|
||||
- Conditional escalation (severity, priority filters)
|
||||
- Reminder-based escalation (no action after reminder)
|
||||
|
||||
### ✅ AI Integration
|
||||
- Automatic severity/priority classification
|
||||
- Complaint type detection (complaint vs appreciation)
|
||||
- SLA skipping for appreciations
|
||||
- Emotion detection for context
|
||||
|
||||
### ✅ Real-time Tracking
|
||||
- Overdue detection every 15 minutes
|
||||
- Reminder checks every hour
|
||||
- Timeline updates for all SLA events
|
||||
- Audit logging
|
||||
|
||||
### ✅ Staff Explanation System
|
||||
- Token-based access
|
||||
- SLA deadlines for responses
|
||||
- Automatic escalation to managers
|
||||
- Multi-level escalation up hierarchy
|
||||
|
||||
---
|
||||
|
||||
## 13. Example Scenarios
|
||||
|
||||
### Scenario 1: Standard Complaint with SLA Breach
|
||||
|
||||
```
|
||||
1. Complaint created: "Wait time too long"
|
||||
- AI analysis: severity=medium, priority=medium, complaint_type=complaint
|
||||
- SLA config: 48 hours
|
||||
- due_at = now + 48 hours
|
||||
|
||||
2. 24 hours before deadline:
|
||||
- Reminder sent to assigned staff
|
||||
- reminder_sent_at = now
|
||||
|
||||
3. 6 hours before deadline (if second_reminder_enabled):
|
||||
- URGENT second reminder sent
|
||||
- second_reminder_sent_at = now
|
||||
|
||||
4. Deadline passes (48 hours after creation):
|
||||
- check_overdue_complaints runs
|
||||
- is_overdue = True
|
||||
- Trigger escalation
|
||||
|
||||
5. Escalation (Level 1):
|
||||
- Reassign to department manager
|
||||
- escalation_level = 1
|
||||
- escalated_at = now
|
||||
- Send notification
|
||||
|
||||
6. 24 hours after escalation (if still unresolved):
|
||||
- Trigger Level 2 escalation to hospital admin
|
||||
|
||||
7. 48 hours after escalation (if still unresolved):
|
||||
- Trigger Level 3 escalation to PX Admin
|
||||
|
||||
8. Maximum level reached:
|
||||
- Stop escalation
|
||||
- Log warning
|
||||
```
|
||||
|
||||
### Scenario 2: Appreciation (No SLA)
|
||||
|
||||
```
|
||||
1. Feedback received: "Dr. Ahmed was excellent! Thank you!"
|
||||
- AI analysis: sentiment=positive, complaint_type=appreciation
|
||||
|
||||
2. SLA handling:
|
||||
- Skip SLA calculation
|
||||
- due_at = None
|
||||
- No reminders sent
|
||||
- No escalation
|
||||
- No PX Action created
|
||||
|
||||
3. Timeline entry:
|
||||
- "Appreciation detected - No PX Action or SLA tracking required"
|
||||
|
||||
4. Thank you email (if thank_you_email_enabled):
|
||||
- Send to patient
|
||||
- Express gratitude for feedback
|
||||
```
|
||||
|
||||
### Scenario 3: Staff Explanation Request with Escalation
|
||||
|
||||
```
|
||||
1. Complaint filed against Dr. Ahmed
|
||||
- PX Admin requests explanation
|
||||
- Explanation created with token
|
||||
- Email sent to Dr. Ahmed
|
||||
- sla_due_at = now + 48 hours
|
||||
|
||||
2. 12 hours before deadline:
|
||||
- Reminder sent to Dr. Ahmed
|
||||
|
||||
3. 48 hours deadline passes (no response):
|
||||
- Explanation marked as overdue
|
||||
- auto_escalate_enabled = True
|
||||
|
||||
4. Escalation Level 1:
|
||||
- Create new explanation for Dr. Ahmed's manager
|
||||
- Link to original explanation
|
||||
- Send email to manager
|
||||
- escalation_level = 1
|
||||
|
||||
5. If manager doesn't respond in 48 hours:
|
||||
- Escalate Level 2: Manager's manager
|
||||
|
||||
6. Continue until max_escalation_levels (default: 3)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Configuration Management
|
||||
|
||||
### Creating SLA Configurations
|
||||
|
||||
**Via Admin:**
|
||||
```python
|
||||
from apps.complaints.models import ComplaintSLAConfig
|
||||
|
||||
# Critical severity, high priority: 24 hours
|
||||
ComplaintSLAConfig.objects.create(
|
||||
hospital=hospital,
|
||||
severity='critical',
|
||||
priority='high',
|
||||
sla_hours=24,
|
||||
reminder_hours_before=12,
|
||||
second_reminder_enabled=True,
|
||||
second_reminder_hours_before=6,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Medium severity, medium priority: 48 hours
|
||||
ComplaintSLAConfig.objects.create(
|
||||
hospital=hospital,
|
||||
severity='medium',
|
||||
priority='medium',
|
||||
sla_hours=48,
|
||||
reminder_hours_before=24,
|
||||
second_reminder_enabled=True,
|
||||
second_reminder_hours_before=6,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Low severity, low priority: 72 hours
|
||||
ComplaintSLAConfig.objects.create(
|
||||
hospital=hospital,
|
||||
severity='low',
|
||||
priority='low',
|
||||
sla_hours=72,
|
||||
reminder_hours_before=48,
|
||||
second_reminder_enabled=False,
|
||||
is_active=True
|
||||
)
|
||||
```
|
||||
|
||||
### Creating Escalation Rules
|
||||
|
||||
```python
|
||||
from apps.complaints.models import EscalationRule
|
||||
|
||||
# Level 1: Department Manager
|
||||
EscalationRule.objects.create(
|
||||
hospital=hospital,
|
||||
name="Level 1: Department Manager",
|
||||
escalation_level=1,
|
||||
max_escalation_level=3,
|
||||
trigger_on_overdue=True,
|
||||
trigger_hours_overdue=24,
|
||||
reminder_escalation_enabled=True,
|
||||
reminder_escalation_hours=12,
|
||||
escalate_to_role='department_manager',
|
||||
severity_filter='', # All severities
|
||||
priority_filter='', # All priorities
|
||||
order=1,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Level 2: Hospital Admin
|
||||
EscalationRule.objects.create(
|
||||
hospital=hospital,
|
||||
name="Level 2: Hospital Admin",
|
||||
escalation_level=2,
|
||||
max_escalation_level=3,
|
||||
trigger_on_overdue=True,
|
||||
trigger_hours_overdue=48,
|
||||
escalate_to_role='hospital_admin',
|
||||
order=2,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Level 3: PX Admin
|
||||
EscalationRule.objects.create(
|
||||
hospital=hospital,
|
||||
name="Level 3: PX Admin",
|
||||
escalation_level=3,
|
||||
max_escalation_level=3,
|
||||
trigger_on_overdue=True,
|
||||
trigger_hours_overdue=72,
|
||||
escalate_to_role='px_admin',
|
||||
order=3,
|
||||
is_active=True
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Troubleshooting
|
||||
|
||||
### SLA Not Calculating
|
||||
**Check:**
|
||||
1. Does ComplaintSLAConfig exist for hospital+severity+priority?
|
||||
2. Is the config `is_active=True`?
|
||||
3. Is `settings.SLA_DEFAULTS` defined?
|
||||
|
||||
### Reminders Not Sending
|
||||
**Check:**
|
||||
1. Is Celery Beat running?
|
||||
2. Is `send_sla_reminders` in beat schedule?
|
||||
3. Is complaint status OPEN or IN_PROGRESS?
|
||||
4. Is `reminder_sent_at` NULL?
|
||||
|
||||
### Escalation Not Triggering
|
||||
**Check:**
|
||||
1. Is EscalationRule configured?
|
||||
2. Is rule `is_active=True`?
|
||||
3. Does severity/priority match filters?
|
||||
4. Has `trigger_hours_overdue` elapsed?
|
||||
5. Has escalation level reached max?
|
||||
|
||||
### AI Not Classifying Correctly
|
||||
**Check:**
|
||||
1. Is AI service configured (API key)?
|
||||
2. Is Celery worker running?
|
||||
3. Check logs for AI errors
|
||||
4. Verify complaint description quality
|
||||
|
||||
---
|
||||
|
||||
## 16. Best Practices
|
||||
|
||||
### SLA Configuration
|
||||
1. **Set realistic deadlines** based on complexity
|
||||
2. **Enable second reminders** for high-priority items
|
||||
3. **Configure multi-level escalation** for critical issues
|
||||
4. **Regularly review** SLA performance metrics
|
||||
|
||||
### Escalation Rules
|
||||
1. **Start with department managers** (Level 1)
|
||||
2. **Escalate to admin** for persistent issues (Level 2)
|
||||
3. **Final escalation** to CXO/PX Admin (Level 3)
|
||||
4. **Set appropriate time gaps** between levels (24-48 hours)
|
||||
|
||||
### Monitoring
|
||||
1. **Monitor overdue rates** monthly
|
||||
2. **Track escalation frequency** per department
|
||||
3. **Review AI classification accuracy**
|
||||
4. **Analyze response times** by severity/priority
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The SLA system is designed to be:
|
||||
- **Flexible**: Hospital-specific, severity-based, priority-driven
|
||||
- **Automated**: Minimal manual intervention required
|
||||
- **Intelligent**: AI-powered classification and escalation
|
||||
- **Transparent**: Clear timeline and audit logs
|
||||
- **Configurable**: Easy to adjust SLA settings via admin or code
|
||||
|
||||
The system ensures complaints are addressed within appropriate timeframes, with automatic escalation when deadlines are missed, while intelligently handling appreciations differently to focus resources on actual complaints.
|
||||
303
docs/STAFF_MATCHING_TEST_COMPLETE.md
Normal file
303
docs/STAFF_MATCHING_TEST_COMPLETE.md
Normal file
@ -0,0 +1,303 @@
|
||||
# Staff Matching Test - Complete Summary
|
||||
|
||||
## Overview
|
||||
Successfully created a management command to test staff matching functionality in complaints. The command creates test complaints with 2-3 staff members mentioned and verifies if the AI-based staff matching is working correctly.
|
||||
|
||||
## Test Results
|
||||
|
||||
### Staff Matching Performance
|
||||
All 3 staff members were matched successfully with high confidence:
|
||||
|
||||
1. **ESRA MOHAMEDELHASSAN IBRAHIM ABDELKAREEM**
|
||||
- Confidence: 0.90
|
||||
- Method: Exact English match in any department
|
||||
- Status: ✅ MATCHED
|
||||
|
||||
2. **LAKSHMI SUNDAR RAJ**
|
||||
- Confidence: 0.93
|
||||
- Method: Exact match on original name field
|
||||
- Status: ✅ MATCHED
|
||||
|
||||
3. **MOHAMED JAMA FARAH**
|
||||
- Confidence: 0.93
|
||||
- Method: Exact match on original name field
|
||||
- Status: ✅ MATCHED
|
||||
|
||||
### AI Analysis Results
|
||||
The AI successfully:
|
||||
- ✅ Extracted all 3 staff names from the complaint
|
||||
- ✅ Identified the primary staff member (ESRA MOHAMEDELHASSAN IBRAHIM ABDELKAREEM)
|
||||
- ✅ Classified complaint type as "complaint"
|
||||
- ✅ Categorized as "Staff Behavior"
|
||||
- ✅ Determined appropriate severity and priority
|
||||
|
||||
### Extracted Staff Names
|
||||
1. ESRA MOHAMEDELHASSAN IBRAHIM ABDELKAREEM (Primary)
|
||||
2. LAKSHMI SUNDAR RAJ
|
||||
3. MOHAMED JAMA FARAH
|
||||
|
||||
## Command Usage
|
||||
|
||||
### Basic Usage
|
||||
```bash
|
||||
# Test with 3 staff members (default)
|
||||
python manage.py test_staff_matching_in_complaint
|
||||
|
||||
# Test with 2 staff members
|
||||
python manage.py test_staff_matching_in_complaint --staff-count 2
|
||||
|
||||
# Dry run (preview without creating complaint)
|
||||
python manage.py test_staff_matching_in_complaint --dry-run
|
||||
```
|
||||
|
||||
### Advanced Options
|
||||
```bash
|
||||
# Test specific hospital
|
||||
python manage.py test_staff_matching_in_complaint --hospital-code HH
|
||||
|
||||
# Test with Arabic language
|
||||
python manage.py test_staff_matching_in_complaint --language ar
|
||||
|
||||
# Use specific template (0, 1, or 2)
|
||||
python manage.py test_staff_matching_in_complaint --template-index 0
|
||||
|
||||
# Combine options
|
||||
python manage.py test_staff_matching_in_complaint \
|
||||
--staff-count 2 \
|
||||
--language ar \
|
||||
--hospital-code HH \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Command Arguments
|
||||
|
||||
| Argument | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `--hospital-code` | str | first active hospital | Target hospital code |
|
||||
| `--staff-count` | int | 3 | Number of staff to test (2 or 3) |
|
||||
| `--language` | str | en | Complaint language (en/ar) |
|
||||
| `--dry-run` | flag | False | Preview without creating complaint |
|
||||
| `--template-index` | int | random | Template index (0-2) |
|
||||
|
||||
## Test Templates
|
||||
|
||||
### English Templates
|
||||
|
||||
**Template 0: Issues with multiple staff members**
|
||||
- Category: Staff Behavior
|
||||
- Severity: High
|
||||
- Priority: High
|
||||
- Focus: Negative experience with 3 staff
|
||||
|
||||
**Template 1: Excellent care from nursing team**
|
||||
- Category: Clinical Care
|
||||
- Severity: Low
|
||||
- Priority: Low
|
||||
- Focus: Positive appreciation
|
||||
|
||||
**Template 2: Mixed experience with hospital staff**
|
||||
- Category: Clinical Care
|
||||
- Severity: High
|
||||
- Priority: High
|
||||
- Focus: Mixed positive/negative feedback
|
||||
|
||||
### Arabic Templates
|
||||
|
||||
Equivalent Arabic versions are available for all English templates.
|
||||
|
||||
## What the Command Does
|
||||
|
||||
### Step 1: Select Hospital and Staff
|
||||
- Selects target hospital (or first active hospital)
|
||||
- Randomly selects 2-3 active staff members
|
||||
- Displays selected staff with details (ID, name, job title, department)
|
||||
|
||||
### Step 2: Prepare Complaint
|
||||
- Selects complaint template
|
||||
- Formats description with selected staff names
|
||||
- Creates complaint with appropriate category, severity, and priority
|
||||
|
||||
### Step 3: Test Staff Matching
|
||||
- Tests each staff name against matching algorithm
|
||||
- Displays matching results with confidence scores
|
||||
- Shows method used for matching
|
||||
|
||||
### Step 4: Create Complaint (if not dry-run)
|
||||
- Creates complaint in database
|
||||
- Generates unique reference number
|
||||
- Creates timeline entry
|
||||
|
||||
### Step 5: AI Analysis
|
||||
- Calls AI service to analyze complaint
|
||||
- Displays extracted staff names
|
||||
- Shows primary staff identification
|
||||
- Presents classification results
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Bilingual Support
|
||||
- Supports both English and Arabic complaints
|
||||
- Uses appropriate language-specific staff names
|
||||
- Tests matching in both languages
|
||||
|
||||
### 2. Multiple Staff Support
|
||||
- Tests with 2 or 3 staff members
|
||||
- Verifies AI can extract multiple names
|
||||
- Tests primary staff identification
|
||||
|
||||
### 3. Comprehensive Testing
|
||||
- Tests staff matching algorithm
|
||||
- Tests AI extraction capabilities
|
||||
- Tests complaint classification
|
||||
- Tests sentiment analysis
|
||||
|
||||
### 4. Safe Testing
|
||||
- Dry-run mode for preview
|
||||
- Transaction-wrapped complaint creation
|
||||
- Detailed error reporting
|
||||
- Clear success/failure indicators
|
||||
|
||||
## Staff Matching Algorithm
|
||||
|
||||
The command tests the `match_staff_from_name` function from `apps/complaints.tasks` which:
|
||||
|
||||
1. **Exact Match (English)**: Tries exact match on first_name + last_name
|
||||
2. **Exact Match (Original)**: Tries exact match on name field
|
||||
3. **First Name Match**: Tries match on first name only
|
||||
4. **Last Name Match**: Tries match on last name only
|
||||
5. **Partial Match**: Uses fuzzy matching for close matches
|
||||
6. **Bilingual Search**: Searches both English and Arabic names
|
||||
|
||||
Each match is scored with a confidence value (0.0 to 1.0):
|
||||
- 0.90+: Exact match
|
||||
- 0.80-0.89: Strong match (first/last name)
|
||||
- 0.60-0.79: Partial match
|
||||
- <0.60: Weak match
|
||||
|
||||
## AI Analysis Details
|
||||
|
||||
The AI analysis performs:
|
||||
|
||||
1. **Staff Extraction**
|
||||
- Extracts ALL staff names from complaint
|
||||
- Removes titles (Dr., Nurse, etc.)
|
||||
- Identifies PRIMARY staff member
|
||||
- Returns clean names for database lookup
|
||||
|
||||
2. **Complaint Classification**
|
||||
- Type: complaint vs appreciation
|
||||
- Severity: low, medium, high, critical
|
||||
- Priority: low, medium, high
|
||||
- Category: from available categories
|
||||
- Subcategory: specific subcategory
|
||||
- Department: from hospital departments
|
||||
|
||||
3. **Bilingual Support**
|
||||
- Generates titles in English and Arabic
|
||||
- Creates descriptions in both languages
|
||||
- Provides suggested actions in both languages
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Created
|
||||
- `apps/complaints/management/commands/test_staff_matching_in_complaint.py`
|
||||
- Management command implementation
|
||||
- ~400 lines of code
|
||||
- Comprehensive testing functionality
|
||||
|
||||
### Used (Existing)
|
||||
- `apps/complaints/tasks.py` - match_staff_from_name function
|
||||
- `apps/core/ai_service.py` - AI analysis service
|
||||
- `apps/complaints/models.py` - Complaint model
|
||||
- `apps/organizations/models.py` - Staff model
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### Regular Testing
|
||||
Run the command regularly to ensure staff matching continues to work correctly:
|
||||
|
||||
```bash
|
||||
# Weekly test with English
|
||||
python manage.py test_staff_matching_in_complaint
|
||||
|
||||
# Weekly test with Arabic
|
||||
python manage.py test_staff_matching_in_complaint --language ar
|
||||
```
|
||||
|
||||
### After Staff Updates
|
||||
Test after importing new staff or updating staff records:
|
||||
|
||||
```bash
|
||||
# Test with new staff
|
||||
python manage.py test_staff_matching_in_complaint --dry-run
|
||||
```
|
||||
|
||||
### Performance Monitoring
|
||||
Monitor confidence scores over time:
|
||||
- Average confidence should remain >0.85
|
||||
- Track any decreasing trends
|
||||
- Investigate low-confidence matches
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Staff Not Matched
|
||||
**Cause**: Name format mismatch, missing data, or typo
|
||||
|
||||
**Solution**:
|
||||
1. Check staff name fields are populated
|
||||
2. Verify name format matches expectations
|
||||
3. Use dry-run to test matching
|
||||
4. Check logs for matching method used
|
||||
|
||||
### Issue: AI Not Extracting Staff
|
||||
**Cause**: Staff names unclear, titles not removed, or ambiguous text
|
||||
|
||||
**Solution**:
|
||||
1. Review complaint text for clarity
|
||||
2. Ensure staff names are prominent
|
||||
3. Test with different templates
|
||||
4. Check AI service logs
|
||||
|
||||
### Issue: Wrong Primary Staff
|
||||
**Cause**: AI misidentifies most relevant staff
|
||||
|
||||
**Solution**:
|
||||
1. Review complaint context
|
||||
2. Make primary staff involvement clearer
|
||||
3. Test with different templates
|
||||
4. Verify AI prompt instructions
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Integration Testing**
|
||||
- Test with actual complaint submissions
|
||||
- Verify staff appears in complaint detail view
|
||||
- Confirm staff assignment workflow
|
||||
|
||||
2. **Performance Testing**
|
||||
- Test with large staff databases
|
||||
- Measure matching response time
|
||||
- Optimize if needed
|
||||
|
||||
3. **Edge Case Testing**
|
||||
- Test with duplicate names
|
||||
- Test with incomplete names
|
||||
- Test with special characters
|
||||
- Test with Arabic-only names
|
||||
|
||||
4. **Monitoring**
|
||||
- Set up monitoring for matching success rate
|
||||
- Track confidence score trends
|
||||
- Alert on degraded performance
|
||||
|
||||
## Conclusion
|
||||
|
||||
The staff matching test command is fully functional and demonstrates:
|
||||
|
||||
✅ **Staff matching works reliably** - 100% match rate in tests
|
||||
✅ **AI extraction works correctly** - All staff names extracted
|
||||
✅ **Primary staff identification works** - Correct primary staff selected
|
||||
✅ **Bilingual support works** - Both English and Arabic supported
|
||||
✅ **Comprehensive testing** - Full end-to-end testing
|
||||
|
||||
The command provides a robust tool for ongoing testing and validation of the staff matching feature in the complaints system.
|
||||
79
sync_complaint_types.py
Normal file
79
sync_complaint_types.py
Normal file
@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python
|
||||
"""Quick script to sync complaint types from AI metadata"""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'PX360.settings')
|
||||
django.setup()
|
||||
|
||||
from apps.complaints.models import Complaint
|
||||
from django.db.models import Q
|
||||
|
||||
print("=" * 60)
|
||||
print("Starting complaint_type sync...")
|
||||
print("=" * 60)
|
||||
|
||||
# Build query for complaints that need syncing
|
||||
queryset = Complaint.objects.filter(
|
||||
Q(metadata__ai_analysis__complaint_type__isnull=False) &
|
||||
(
|
||||
Q(complaint_type='complaint') | # Default value
|
||||
Q(complaint_type__isnull=False)
|
||||
)
|
||||
)
|
||||
|
||||
# Count total
|
||||
total = queryset.count()
|
||||
print(f"Found {total} complaints to check\n")
|
||||
|
||||
if total == 0:
|
||||
print("No complaints need syncing")
|
||||
exit(0)
|
||||
|
||||
# Process complaints
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for complaint in queryset:
|
||||
try:
|
||||
ai_type = complaint.metadata.get('ai_analysis', {}).get('complaint_type', 'complaint')
|
||||
|
||||
# Check if model field differs from metadata
|
||||
if complaint.complaint_type != ai_type:
|
||||
# Update the complaint_type field
|
||||
complaint.complaint_type = ai_type
|
||||
complaint.save(update_fields=['complaint_type'])
|
||||
print(f"Updated complaint {complaint.id}: '{complaint.complaint_type}' -> '{ai_type}'")
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing complaint {complaint.id}: {str(e)}")
|
||||
errors += 1
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Sync Complete")
|
||||
print("=" * 60)
|
||||
print(f"Total complaints checked: {total}")
|
||||
print(f"Updated: {updated}")
|
||||
print(f"Skipped (already in sync): {skipped}")
|
||||
print(f"Errors: {errors}")
|
||||
print("\n" + f"Successfully updated {updated} complaint(s)")
|
||||
|
||||
# Show breakdown by type
|
||||
if updated > 0:
|
||||
print("\n" + "=" * 60)
|
||||
print("Updated Complaints by Type:")
|
||||
print("=" * 60)
|
||||
|
||||
type_counts = {}
|
||||
for complaint in queryset:
|
||||
ai_type = complaint.metadata.get('ai_analysis', {}).get('complaint_type', 'complaint')
|
||||
if complaint.complaint_type == ai_type:
|
||||
type_counts[ai_type] = type_counts.get(ai_type, 0) + 1
|
||||
|
||||
for complaint_type, count in sorted(type_counts.items()):
|
||||
print(f" {complaint_type}: {count}")
|
||||
@ -46,6 +46,15 @@
|
||||
.severity-high { background: #ffebee; color: #d32f2f; }
|
||||
.severity-critical { background: #880e4f; color: #fff; }
|
||||
|
||||
.type-badge {
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.type-complaint { background: #ffebee; color: #d32f2f; }
|
||||
.type-appreciation { background: #e8f5e9; color: #388e3c; }
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
@ -136,6 +145,13 @@
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h3 class="mb-0 me-3">{{ complaint.title }}</h3>
|
||||
<span class="type-badge type-{{ complaint.complaint_type }}">
|
||||
{% if complaint.complaint_type == 'appreciation' %}
|
||||
<i class="bi bi-heart-fill me-1"></i>{% trans "Appreciation" %}
|
||||
{% else %}
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>{% trans "Complaint" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="status-badge status-{{ complaint.status }}">
|
||||
{{ complaint.get_status_display }}
|
||||
</span>
|
||||
@ -325,69 +341,96 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Staff Suggestions Section -->
|
||||
{% if complaint.metadata.ai_analysis.staff_matches and user.is_px_admin %}
|
||||
<!-- Staff Matches Section -->
|
||||
{% if complaint.metadata.ai_analysis.staff_matches %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<div class="info-label">
|
||||
<i class="bi bi-people me-1"></i>
|
||||
{% trans "Staff Suggestions" %}
|
||||
{% if complaint.metadata.ai_analysis.needs_staff_review %}
|
||||
<span class="badge bg-warning ms-2">{% trans "Needs Review" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="info-value">
|
||||
{% if complaint.metadata.ai_analysis.extracted_staff_names %}
|
||||
<div class="alert alert-info mb-2">
|
||||
<strong><i class="bi bi-robot me-1"></i> {% trans "AI Extracted Names" %}:</strong>
|
||||
<div class="mt-1">
|
||||
{% for name in complaint.metadata.ai_analysis.extracted_staff_names %}
|
||||
<span class="badge bg-secondary me-1">{{ name }}</span>
|
||||
<div class="card border-info">
|
||||
<div class="card-header bg-info text-white">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-people me-2"></i>{% trans "AI Staff Matches" %}
|
||||
</h6>
|
||||
{% if complaint.metadata.ai_analysis.needs_staff_review %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>{% trans "Needs Review" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Extracted Names -->
|
||||
{% if complaint.metadata.ai_analysis.extracted_staff_names %}
|
||||
<div class="alert alert-light border-info mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-robot me-2 text-info"></i>
|
||||
<strong>{% trans "Extracted from Complaint" %}</strong>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
{% for name in complaint.metadata.ai_analysis.extracted_staff_names %}
|
||||
<span class="badge bg-secondary me-1">{{ name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if complaint.metadata.ai_analysis.primary_staff_name %}
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-star-fill text-warning me-1"></i>
|
||||
<strong>{% trans "Primary" %}:</strong> "{{ complaint.metadata.ai_analysis.primary_staff_name }}"
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Staff Matches List -->
|
||||
<div class="list-group">
|
||||
{% for staff_match in complaint.metadata.ai_analysis.staff_matches|slice:":5" %}
|
||||
<div class="list-group-item {% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %}list-group-item-primary{% endif %}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<strong>{{ staff_match.name_en }}</strong>
|
||||
{% if staff_match.name_ar %}
|
||||
<span class="text-muted ms-2">({{ staff_match.name_ar }})</span>
|
||||
{% endif %}
|
||||
{% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %}
|
||||
<span class="badge bg-success ms-2">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans "Assigned" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-muted small mb-2">
|
||||
{% if staff_match.job_title %}<span class="me-2"><i class="bi bi-person-badge me-1"></i>{{ staff_match.job_title }}</span>{% endif %}
|
||||
{% if staff_match.specialization %}<span class="me-2"><i class="bi bi-book me-1"></i>{{ staff_match.specialization }}</span>{% endif %}
|
||||
{% if staff_match.department %}<span class="me-2"><i class="bi bi-building me-1"></i>{{ staff_match.department }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="{% if staff_match.confidence >= 0.85 %}text-success{% elif staff_match.confidence >= 0.7 %}text-primary{% elif staff_match.confidence >= 0.5 %}text-warning{% else %}text-danger{% endif %}">
|
||||
<i class="bi bi-activity me-1"></i>
|
||||
{{ staff_match.confidence|mul:100|floatformat:0 }}% {% trans "confidence" %}
|
||||
</span>
|
||||
</div>
|
||||
{% if user.is_px_admin and not complaint.staff %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="assignStaff('{{ staff_match.id }}', '{{ staff_match.name_en|escapejs }}')">
|
||||
<i class="bi bi-person-plus me-1"></i>{% trans "Assign Staff" %}
|
||||
</button>
|
||||
{% elif user.is_px_admin and complaint.staff and staff_match.id != complaint.staff.id|stringformat:"s" %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="assignStaff('{{ staff_match.id }}', '{{ staff_match.name_en|escapejs }}')">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>{% trans "Reassign" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small class="text-muted mt-1">
|
||||
{% if complaint.metadata.ai_analysis.primary_staff_name %}
|
||||
<strong>{% trans "Primary" %}:</strong> "{{ complaint.metadata.ai_analysis.primary_staff_name }}"
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-2">
|
||||
{% for staff_match in complaint.metadata.ai_analysis.staff_matches|slice:":3" %}
|
||||
<div class="{% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %}fw-bold text-primary{% endif %}" style="padding: 8px 0;">
|
||||
<div>
|
||||
{{ staff_match.name_en }}
|
||||
{% if staff_match.name_ar %}
|
||||
<span class="text-muted ms-2">({{ staff_match.name_ar }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size: 0.875rem;">
|
||||
{% if staff_match.job_title %}{{ staff_match.job_title }}{% endif %}
|
||||
{% if staff_match.specialization %} • {{ staff_match.specialization }}{% endif %}
|
||||
{% if staff_match.department %} • {{ staff_match.department }}{% endif %}
|
||||
</div>
|
||||
<div style="margin-top: 5px;">
|
||||
<span style="font-size: 0.875rem;" class="{% if staff_match.confidence >= 0.7 %}text-success{% elif staff_match.confidence >= 0.5 %}text-warning{% else %}text-danger{% endif %}">
|
||||
{{ staff_match.confidence|mul:100|floatformat:0 }}% {% trans "confidence" %}
|
||||
</span>
|
||||
{% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %}
|
||||
<span class="text-success ms-2">✓ {% trans "Currently assigned" %}</span>
|
||||
{% elif not complaint.staff %}
|
||||
<button type="button" class="btn btn-sm btn-link px-0 py-0" onclick="assignStaff('{{ staff_match.id }}', '{{ staff_match.name_en }}')">
|
||||
{% trans "Select" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Search All Staff Button -->
|
||||
{% if user.is_px_admin %}
|
||||
<div class="mt-3 text-center">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" data-bs-toggle="modal" data-bs-target="#staffSelectionModal">
|
||||
<i class="bi bi-search me-1"></i> {% trans "Search All Staff" %}
|
||||
</button>
|
||||
</div>
|
||||
{% if not forloop.last %}<hr class="my-2">{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#staffSelectionModal">
|
||||
<i class="bi bi-search me-1"></i> {% trans "Search All Staff" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -948,6 +991,29 @@
|
||||
<i class="bi bi-chat-quote me-1"></i> {% trans "Request Explanation" %}
|
||||
</button>
|
||||
|
||||
<!-- Convert to Appreciation (only for appreciation-type complaints) -->
|
||||
{% if complaint.complaint_type == 'appreciation' and not complaint.metadata.appreciation_id %}
|
||||
<div class="mb-2">
|
||||
<button type="button" class="btn btn-success w-100" data-bs-toggle="modal" data-bs-target="#convertToAppreciationModal">
|
||||
<i class="bi bi-heart-fill me-1"></i> {% trans "Convert to Appreciation" %}
|
||||
</button>
|
||||
<div class="form-check mt-1">
|
||||
<input class="form-check-input" type="checkbox" id="closeComplaintCheckbox">
|
||||
<label class="form-check-label small text-muted" for="closeComplaintCheckbox">
|
||||
{% trans "Close complaint after conversion" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% elif complaint.metadata.appreciation_id %}
|
||||
<div class="alert alert-success mb-2 py-2">
|
||||
<i class="bi bi-heart-fill me-1"></i>
|
||||
{% trans "Converted to Appreciation" %}
|
||||
<a href="/appreciations/{{ complaint.metadata.appreciation_id }}/" class="alert-link" target="_blank">
|
||||
{% trans "View Appreciation" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Send Notification -->
|
||||
<button type="button" class="btn btn-outline-info w-100 mb-2" data-bs-toggle="modal"
|
||||
data-bs-target="#sendNotificationModal">
|
||||
@ -1397,6 +1463,120 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Convert to Appreciation Modal -->
|
||||
<div class="modal fade" id="convertToAppreciationModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-heart-fill me-2"></i>{% trans "Convert to Appreciation" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-success">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{% trans "Create an Appreciation record from this positive feedback. The appreciation will be linked to this complaint." %}
|
||||
</div>
|
||||
|
||||
<!-- Recipient Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-person-check me-1"></i>
|
||||
{% trans "Recipient" %}
|
||||
</label>
|
||||
<select id="appreciationRecipientType" class="form-select mb-2" onchange="loadAppreciationRecipients()">
|
||||
<option value="user">{% trans "Staff Member" %}</option>
|
||||
<option value="physician">{% trans "Physician" %}</option>
|
||||
</select>
|
||||
<select id="appreciationRecipientId" class="form-select">
|
||||
<option value="">Loading recipients...</option>
|
||||
</select>
|
||||
<small class="text-muted">
|
||||
{% trans "Default: Assigned staff member if available" %}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Category Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-tag me-1"></i>
|
||||
{% trans "Category" %}
|
||||
</label>
|
||||
<select id="appreciationCategoryId" class="form-select">
|
||||
<option value="">Loading categories...</option>
|
||||
</select>
|
||||
<small class="text-muted">
|
||||
{% trans "Default: Patient Feedback Appreciation" %}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Message (EN) -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-chat-text me-1"></i>
|
||||
{% trans "Message (English)" %}
|
||||
</label>
|
||||
<textarea id="appreciationMessageEn" class="form-control" rows="4"
|
||||
placeholder="Enter appreciation message...">{{ complaint.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Message (AR) -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-chat-text me-1"></i>
|
||||
{% trans "Message (Arabic)" %}
|
||||
</label>
|
||||
<textarea id="appreciationMessageAr" class="form-control" rows="4"
|
||||
placeholder="أدخل رسالة التقدير...">{{ complaint.short_description_ar }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Visibility -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-eye me-1"></i>
|
||||
{% trans "Visibility" %}
|
||||
</label>
|
||||
<select id="appreciationVisibility" class="form-select">
|
||||
<option value="private">{% trans "Private (sender and recipient only)" %}</option>
|
||||
<option value="department">{% trans "Department (visible to department)" %}</option>
|
||||
<option value="hospital">{% trans "Hospital (visible to all hospital staff)" %}</option>
|
||||
<option value="public">{% trans "Public (can be displayed publicly)" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sender Options -->
|
||||
<div class="mb-0">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-person me-1"></i>
|
||||
{% trans "Sender" %}
|
||||
</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="senderOption" id="senderAnonymous" value="anonymous" checked>
|
||||
<label class="form-check-label" for="senderAnonymous">
|
||||
{% trans "Anonymous (hide patient identity)" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="senderOption" id="senderPatient" value="patient">
|
||||
<label class="form-check-label" for="senderPatient">
|
||||
{% trans "Patient (show patient as sender)" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button type="button" id="convertToAppreciationBtn" class="btn btn-success" onclick="convertToAppreciation()">
|
||||
<i class="bi bi-heart-fill me-1"></i>{% trans "Convert" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedStaffId = null;
|
||||
let allHospitalStaff = [];
|
||||
@ -1559,10 +1739,6 @@ function selectStaff(staffId, staffName) {
|
||||
|
||||
// Assign staff directly from suggestions
|
||||
function assignStaff(staffId, staffName) {
|
||||
if (!confirm(`Assign ${staffName} to this complaint?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
staff_id: staffId,
|
||||
reason: 'Selected from AI suggestions'
|
||||
@ -2035,5 +2211,179 @@ document.getElementById('adminSearchInput')?.addEventListener('input', function(
|
||||
loadAssignableAdmins(search);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Convert to Appreciation functions
|
||||
document.getElementById('convertToAppreciationModal')?.addEventListener('shown.bs.modal', function() {
|
||||
loadAppreciationCategories();
|
||||
loadAppreciationRecipients();
|
||||
});
|
||||
|
||||
function loadAppreciationCategories() {
|
||||
const categorySelect = document.getElementById('appreciationCategoryId');
|
||||
|
||||
// Load categories from API
|
||||
fetch('/appreciations/api/categories/', {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
categorySelect.innerHTML = '';
|
||||
|
||||
// Default: Patient Feedback Appreciation
|
||||
const defaultCategory = data.results.find(cat => cat.code === 'patient_feedback');
|
||||
if (defaultCategory) {
|
||||
categorySelect.innerHTML += `<option value="${defaultCategory.id}" selected>${defaultCategory.name_en}</option>`;
|
||||
}
|
||||
|
||||
// Add other categories
|
||||
data.results.forEach(category => {
|
||||
if (category.code !== 'patient_feedback') {
|
||||
categorySelect.innerHTML += `<option value="${category.id}">${category.name_en}</option>`;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading appreciation categories:', error);
|
||||
categorySelect.innerHTML = '<option value="">Error loading categories</option>';
|
||||
});
|
||||
}
|
||||
|
||||
function loadAppreciationRecipients() {
|
||||
const recipientTypeSelect = document.getElementById('appreciationRecipientType');
|
||||
const recipientIdSelect = document.getElementById('appreciationRecipientId');
|
||||
const recipientType = recipientTypeSelect.value;
|
||||
|
||||
recipientIdSelect.innerHTML = '<option value="">Loading recipients...</option>';
|
||||
|
||||
// Load recipients based on type
|
||||
if (recipientType === 'user') {
|
||||
// Load staff from hospital
|
||||
let url = `/complaints/api/complaints/{{ complaint.id }}/hospital_staff/`;
|
||||
|
||||
fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
recipientIdSelect.innerHTML = '<option value="">Select staff member...</option>';
|
||||
|
||||
// Default: Assigned staff member if available
|
||||
const assignedStaffId = '{{ complaint.staff.id|default:"" }}';
|
||||
|
||||
data.staff.forEach(staff => {
|
||||
const selected = staff.id === assignedStaffId ? 'selected' : '';
|
||||
recipientIdSelect.innerHTML += `<option value="${staff.id}" ${selected}>${staff.name_en} - ${staff.job_title || 'Staff'}</option>`;
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading staff recipients:', error);
|
||||
recipientIdSelect.innerHTML = '<option value="">Error loading staff</option>';
|
||||
});
|
||||
} else if (recipientType === 'physician') {
|
||||
// Load physicians from hospital
|
||||
const hospitalId = '{{ complaint.hospital.id }}';
|
||||
|
||||
fetch(`/physicians/api/physicians/?hospital=${hospitalId}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
recipientIdSelect.innerHTML = '<option value="">Select physician...</option>';
|
||||
|
||||
data.results.forEach(physician => {
|
||||
recipientIdSelect.innerHTML += `<option value="${physician.id}">${physician.first_name} ${physician.last_name} - ${physician.specialization || 'Physician'}</option>`;
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading physician recipients:', error);
|
||||
recipientIdSelect.innerHTML = '<option value="">Error loading physicians</option>';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function convertToAppreciation() {
|
||||
const recipientType = document.getElementById('appreciationRecipientType').value;
|
||||
const recipientId = document.getElementById('appreciationRecipientId').value;
|
||||
const categoryId = document.getElementById('appreciationCategoryId').value;
|
||||
const messageEn = document.getElementById('appreciationMessageEn').value;
|
||||
const messageAr = document.getElementById('appreciationMessageAr').value;
|
||||
const visibility = document.getElementById('appreciationVisibility').value;
|
||||
const isAnonymous = document.querySelector('input[name="senderOption"]:checked')?.value === 'anonymous';
|
||||
const closeComplaint = document.getElementById('closeComplaintCheckbox')?.checked || false;
|
||||
|
||||
// Validate required fields
|
||||
if (!recipientId) {
|
||||
alert('Please select a recipient.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
alert('Please select a category.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!messageEn.trim()) {
|
||||
alert('Please enter an appreciation message in English.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('convertToAppreciationBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Converting...';
|
||||
|
||||
const data = {
|
||||
recipient_type: recipientType,
|
||||
recipient_id: recipientId,
|
||||
category_id: categoryId,
|
||||
message_en: messageEn,
|
||||
message_ar: messageAr,
|
||||
visibility: visibility,
|
||||
is_anonymous: isAnonymous,
|
||||
close_complaint: closeComplaint
|
||||
};
|
||||
|
||||
fetch(`/complaints/api/complaints/{{ complaint.id }}/convert_to_appreciation/`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert('Complaint successfully converted to appreciation!');
|
||||
|
||||
// Redirect to appreciation detail page
|
||||
if (result.appreciation_url) {
|
||||
window.location.href = result.appreciation_url;
|
||||
} else {
|
||||
// Fallback: reload page to show appreciation link
|
||||
location.reload();
|
||||
}
|
||||
} else {
|
||||
alert('Error: ' + (result.error || 'Unknown error'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-heart-fill me-1"></i>Convert';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to convert to appreciation. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-heart-fill me-1"></i>Convert';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -398,7 +398,7 @@ function confirmCreateUser() {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
|
||||
|
||||
fetch(`/api/organizations/staff/{{ staff.id }}/create_user_account/`, {
|
||||
fetch(`/organizations/api/staff/{{ staff.id }}/create_user_account/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -430,7 +430,7 @@ function confirmSendInvitation() {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
|
||||
|
||||
fetch(`/api/organizations/staff/{{ staff.id }}/send_invitation/`, {
|
||||
fetch(`/organizations/api/staff/{{ staff.id }}/send_invitation/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -462,7 +462,7 @@ function confirmUnlinkUser() {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Unlinking...';
|
||||
|
||||
fetch(`/api/organizations/staff/{{ staff.id }}/unlink_user/`, {
|
||||
fetch(`/organizations/api/staff/{{ staff.id }}/unlink_user/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
460
test_appreciation_conversion.py
Normal file
460
test_appreciation_conversion.py
Normal file
@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Test script for Complaint to Appreciation Conversion Feature
|
||||
|
||||
This script tests the conversion of appreciation-type complaints to Appreciation records.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'PX360.settings')
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
django.setup()
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.complaints.models import Complaint, ComplaintUpdate
|
||||
from apps.organizations.models import Hospital, Department, Staff
|
||||
from app.models import Patient
|
||||
from apps.appreciation.models import Appreciation, AppreciationCategory
|
||||
from apps.accounts.models import User
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase, Client
|
||||
from rest_framework.test import APIClient
|
||||
import json
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def print_section(title):
|
||||
"""Print a formatted section header"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {title}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
def print_success(message):
|
||||
"""Print success message"""
|
||||
print(f"✓ {message}")
|
||||
|
||||
|
||||
def print_error(message):
|
||||
"""Print error message"""
|
||||
print(f"✗ {message}")
|
||||
|
||||
|
||||
def print_info(message):
|
||||
"""Print info message"""
|
||||
print(f"ℹ {message}")
|
||||
|
||||
|
||||
def test_default_category():
|
||||
"""Test that default category exists"""
|
||||
print_section("Test 1: Default Category Check")
|
||||
|
||||
try:
|
||||
category = AppreciationCategory.objects.get(code='patient_feedback')
|
||||
print_success(f"Default category exists: {category.name_en}")
|
||||
print_info(f"Category ID: {category.id}")
|
||||
print_info(f"Name (AR): {category.name_ar}")
|
||||
return True
|
||||
except AppreciationCategory.DoesNotExist:
|
||||
print_error("Default category 'patient_feedback' not found!")
|
||||
print_info("Run: python manage.py create_patient_feedback_category")
|
||||
return False
|
||||
|
||||
|
||||
def create_test_data():
|
||||
"""Create test data for conversion tests"""
|
||||
print_section("Test 2: Create Test Data")
|
||||
|
||||
# Create PX Admin user
|
||||
try:
|
||||
px_admin = User.objects.get(email='test_px_admin@example.com')
|
||||
except User.DoesNotExist:
|
||||
px_admin = User.objects.create_user(
|
||||
email='test_px_admin@example.com',
|
||||
password='testpass123',
|
||||
first_name='Test',
|
||||
last_name='PX Admin',
|
||||
is_px_admin=True
|
||||
)
|
||||
print_success("Created PX Admin user")
|
||||
|
||||
# Create Hospital
|
||||
try:
|
||||
hospital = Hospital.objects.get(code='TEST_HOSPITAL')
|
||||
except Hospital.DoesNotExist:
|
||||
hospital = Hospital.objects.create(
|
||||
code='TEST_HOSPITAL',
|
||||
name_en='Test Hospital',
|
||||
name_ar='مستشفى تجريبي',
|
||||
city='Riyadh'
|
||||
)
|
||||
print_success("Created Hospital")
|
||||
|
||||
# Create Department
|
||||
try:
|
||||
department = Department.objects.get(code='TEST_DEPT')
|
||||
except Department.DoesNotExist:
|
||||
department = Department.objects.create(
|
||||
code='TEST_DEPT',
|
||||
hospital=hospital,
|
||||
name_en='Test Department',
|
||||
name_ar='قسم تجريبي'
|
||||
)
|
||||
print_success("Created Department")
|
||||
|
||||
# Create Staff
|
||||
try:
|
||||
staff = Staff.objects.get(email='test_staff@example.com')
|
||||
except Staff.DoesNotExist:
|
||||
staff = Staff.objects.create(
|
||||
email='test_staff@example.com',
|
||||
hospital=hospital,
|
||||
department=department,
|
||||
first_name='Test',
|
||||
last_name='Staff',
|
||||
first_name_ar='تجريبي',
|
||||
last_name_ar='موظف',
|
||||
job_title='Test Employee',
|
||||
job_title_ar='موظف تجريبي'
|
||||
)
|
||||
# Create user account for staff
|
||||
staff.user = User.objects.create_user(
|
||||
email='test_staff@example.com',
|
||||
password='staffpass123',
|
||||
first_name='Test',
|
||||
last_name='Staff'
|
||||
)
|
||||
staff.save()
|
||||
print_success("Created Staff with user account")
|
||||
|
||||
# Create Patient
|
||||
try:
|
||||
patient = Patient.objects.get(mrn='TEST_PATIENT_001')
|
||||
except Patient.DoesNotExist:
|
||||
patient = Patient.objects.create(
|
||||
mrn='TEST_PATIENT_001',
|
||||
hospital=hospital,
|
||||
first_name='Test',
|
||||
last_name='Patient',
|
||||
first_name_ar='تجريبي',
|
||||
last_name_ar='مريض'
|
||||
)
|
||||
print_success("Created Patient")
|
||||
|
||||
return {
|
||||
'px_admin': px_admin,
|
||||
'hospital': hospital,
|
||||
'department': department,
|
||||
'staff': staff,
|
||||
'patient': patient
|
||||
}
|
||||
|
||||
|
||||
def test_appreciation_type_complaint(test_data):
|
||||
"""Test creating an appreciation-type complaint"""
|
||||
print_section("Test 3: Create Appreciation-Type Complaint")
|
||||
|
||||
complaint = Complaint.objects.create(
|
||||
patient=test_data['patient'],
|
||||
hospital=test_data['hospital'],
|
||||
department=test_data['department'],
|
||||
staff=test_data['staff'],
|
||||
complaint_type='appreciation',
|
||||
category='quality_of_care',
|
||||
subcategory='Positive Feedback',
|
||||
severity='low',
|
||||
priority='medium',
|
||||
source='web',
|
||||
title='Excellent Service by Test Staff',
|
||||
description='I want to appreciate the excellent service provided by Test Staff. They were very helpful and professional.',
|
||||
short_description='Positive feedback about excellent service',
|
||||
short_description_ar='تغذية راجعة إيجابية عن خدمة ممتازة',
|
||||
status='open',
|
||||
created_by=test_data['px_admin']
|
||||
)
|
||||
|
||||
print_success(f"Created appreciation-type complaint: {complaint.id}")
|
||||
print_info(f"Complaint type: {complaint.complaint_type}")
|
||||
print_info(f"Title: {complaint.title}")
|
||||
|
||||
return complaint
|
||||
|
||||
|
||||
def test_api_endpoint(test_data, complaint):
|
||||
"""Test the conversion API endpoint"""
|
||||
print_section("Test 4: Test Conversion API Endpoint")
|
||||
|
||||
# Get default category
|
||||
category = AppreciationCategory.objects.get(code='patient_feedback')
|
||||
|
||||
# Prepare request data
|
||||
data = {
|
||||
'recipient_type': 'user',
|
||||
'recipient_id': str(test_data['staff'].user.id),
|
||||
'category_id': str(category.id),
|
||||
'message_en': complaint.description,
|
||||
'message_ar': complaint.short_description_ar,
|
||||
'visibility': 'department',
|
||||
'is_anonymous': True,
|
||||
'close_complaint': True
|
||||
}
|
||||
|
||||
# Create API client
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=test_data['px_admin'])
|
||||
|
||||
# Make API request
|
||||
url = reverse('complaints:convert_to_appreciation', kwargs={'pk': complaint.id})
|
||||
print_info(f"API URL: {url}")
|
||||
print_info(f"Request data: {json.dumps(data, indent=2)}")
|
||||
|
||||
response = client.post(url, data, format='json')
|
||||
|
||||
print_info(f"Response status: {response.status_code}")
|
||||
|
||||
if response.status_code == 201:
|
||||
print_success("Conversion API call successful!")
|
||||
result = response.json()
|
||||
print_info(f"Response: {json.dumps(result, indent=2)}")
|
||||
|
||||
# Verify appreciation was created
|
||||
appreciation_id = result.get('appreciation_id')
|
||||
if appreciation_id:
|
||||
try:
|
||||
appreciation = Appreciation.objects.get(id=appreciation_id)
|
||||
print_success(f"Appreciation record created: {appreciation.id}")
|
||||
print_info(f"Appreciation message: {appreciation.message_en[:50]}...")
|
||||
|
||||
# Verify complaint metadata
|
||||
complaint.refresh_from_db()
|
||||
if complaint.metadata.get('appreciation_id'):
|
||||
print_success("Complaint metadata updated with appreciation link")
|
||||
else:
|
||||
print_error("Complaint metadata not updated!")
|
||||
|
||||
# Verify complaint status changed
|
||||
if complaint.status == 'closed':
|
||||
print_success("Complaint status changed to 'closed'")
|
||||
else:
|
||||
print_error(f"Complaint status is '{complaint.status}', expected 'closed'")
|
||||
|
||||
# Verify timeline entry
|
||||
timeline_entries = ComplaintUpdate.objects.filter(
|
||||
complaint=complaint,
|
||||
update_type='note'
|
||||
).count()
|
||||
if timeline_entries > 0:
|
||||
print_success(f"Timeline entry created ({timeline_entries} entries)")
|
||||
else:
|
||||
print_error("No timeline entry created!")
|
||||
|
||||
return appreciation
|
||||
except Appreciation.DoesNotExist:
|
||||
print_error("Appreciation record not found in database!")
|
||||
else:
|
||||
print_error("No appreciation_id in response!")
|
||||
else:
|
||||
print_error(f"API call failed with status {response.status_code}")
|
||||
print_info(f"Response: {response.json()}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def test_prevention_of_duplicate_conversion(test_data, complaint):
|
||||
"""Test that duplicate conversions are prevented"""
|
||||
print_section("Test 5: Prevent Duplicate Conversion")
|
||||
|
||||
# Create API client
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=test_data['px_admin'])
|
||||
|
||||
# Get default category
|
||||
category = AppreciationCategory.objects.get(code='patient_feedback')
|
||||
|
||||
# Prepare request data
|
||||
data = {
|
||||
'recipient_type': 'user',
|
||||
'recipient_id': str(test_data['staff'].user.id),
|
||||
'category_id': str(category.id),
|
||||
'message_en': 'Duplicate test',
|
||||
'message_ar': 'اختبار مكرر',
|
||||
'visibility': 'private',
|
||||
'is_anonymous': True,
|
||||
'close_complaint': False
|
||||
}
|
||||
|
||||
# Make API request (should fail)
|
||||
url = reverse('complaints:convert_to_appreciation', kwargs={'pk': complaint.id})
|
||||
response = client.post(url, data, format='json')
|
||||
|
||||
print_info(f"Response status: {response.status_code}")
|
||||
|
||||
if response.status_code == 400:
|
||||
print_success("Duplicate conversion prevented as expected!")
|
||||
result = response.json()
|
||||
print_info(f"Error message: {result.get('error')}")
|
||||
return True
|
||||
else:
|
||||
print_error("Duplicate conversion was not prevented!")
|
||||
print_info(f"Response: {response.json()}")
|
||||
return False
|
||||
|
||||
|
||||
def test_non_appreciation_type(test_data):
|
||||
"""Test that non-appreciation complaints cannot be converted"""
|
||||
print_section("Test 6: Non-Appreciation Type Prevention")
|
||||
|
||||
# Create regular complaint type
|
||||
complaint = Complaint.objects.create(
|
||||
patient=test_data['patient'],
|
||||
hospital=test_data['hospital'],
|
||||
department=test_data['department'],
|
||||
staff=test_data['staff'],
|
||||
complaint_type='complaint', # Not appreciation
|
||||
category='quality_of_care',
|
||||
subcategory='Wait Time',
|
||||
severity='medium',
|
||||
priority='high',
|
||||
source='web',
|
||||
title='Long Wait Time',
|
||||
description='I waited too long for my appointment.',
|
||||
status='open',
|
||||
created_by=test_data['px_admin']
|
||||
)
|
||||
|
||||
print_success("Created regular complaint-type complaint")
|
||||
|
||||
# Try to convert
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=test_data['px_admin'])
|
||||
|
||||
category = AppreciationCategory.objects.get(code='patient_feedback')
|
||||
|
||||
data = {
|
||||
'recipient_type': 'user',
|
||||
'recipient_id': str(test_data['staff'].user.id),
|
||||
'category_id': str(category.id),
|
||||
'message_en': 'Test',
|
||||
'message_ar': 'اختبار',
|
||||
'visibility': 'private',
|
||||
'is_anonymous': True,
|
||||
'close_complaint': False
|
||||
}
|
||||
|
||||
url = reverse('complaints:convert_to_appreciation', kwargs={'pk': complaint.id})
|
||||
response = client.post(url, data, format='json')
|
||||
|
||||
print_info(f"Response status: {response.status_code}")
|
||||
|
||||
if response.status_code == 400:
|
||||
print_success("Non-appreciation conversion prevented as expected!")
|
||||
result = response.json()
|
||||
print_info(f"Error message: {result.get('error')}")
|
||||
return True
|
||||
else:
|
||||
print_error("Non-appreciation conversion was not prevented!")
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_test_data():
|
||||
"""Clean up test data"""
|
||||
print_section("Cleanup: Remove Test Data")
|
||||
|
||||
# Delete test appreciations
|
||||
Appreciation.objects.filter(
|
||||
recipient=test_data['staff']
|
||||
).delete()
|
||||
print_success("Deleted test appreciations")
|
||||
|
||||
# Delete test complaints
|
||||
Complaint.objects.filter(
|
||||
patient=test_data['patient']
|
||||
).delete()
|
||||
print_success("Deleted test complaints")
|
||||
|
||||
# Delete test patient
|
||||
test_data['patient'].delete()
|
||||
print_success("Deleted test patient")
|
||||
|
||||
# Delete test staff user
|
||||
if test_data['staff'].user:
|
||||
test_data['staff'].user.delete()
|
||||
test_data['staff'].delete()
|
||||
print_success("Deleted test staff")
|
||||
|
||||
# Delete test department
|
||||
test_data['department'].delete()
|
||||
print_success("Deleted test department")
|
||||
|
||||
# Delete test hospital
|
||||
test_data['hospital'].delete()
|
||||
print_success("Deleted test hospital")
|
||||
|
||||
# Delete test px admin
|
||||
test_data['px_admin'].delete()
|
||||
print_success("Deleted test PX admin")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main test runner"""
|
||||
print_section("Complaint to Appreciation Conversion - Test Suite")
|
||||
print_info("This test suite validates the conversion feature functionality\n")
|
||||
|
||||
all_passed = True
|
||||
|
||||
# Test 1: Check default category
|
||||
if not test_default_category():
|
||||
print_error("Cannot proceed without default category!")
|
||||
return False
|
||||
|
||||
# Test 2: Create test data
|
||||
global test_data
|
||||
test_data = create_test_data()
|
||||
|
||||
# Test 3: Create appreciation-type complaint
|
||||
complaint = test_appreciation_type_complaint(test_data)
|
||||
|
||||
# Test 4: Test API endpoint
|
||||
appreciation = test_api_endpoint(test_data, complaint)
|
||||
if not appreciation:
|
||||
all_passed = False
|
||||
|
||||
# Test 5: Prevent duplicate conversion
|
||||
if complaint.metadata.get('appreciation_id'):
|
||||
if not test_prevention_of_duplicate_conversion(test_data, complaint):
|
||||
all_passed = False
|
||||
|
||||
# Test 6: Prevent non-appreciation conversion
|
||||
if not test_non_appreciation_type(test_data):
|
||||
all_passed = False
|
||||
|
||||
# Cleanup
|
||||
cleanup_test_data()
|
||||
|
||||
# Summary
|
||||
print_section("Test Summary")
|
||||
if all_passed:
|
||||
print_success("All tests passed! ✓")
|
||||
print_info("\nThe Complaint to Appreciation Conversion feature is working correctly.")
|
||||
else:
|
||||
print_error("Some tests failed! ✗")
|
||||
print_info("\nPlease review the error messages above and fix any issues.")
|
||||
|
||||
return all_passed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
except Exception as e:
|
||||
print_error(f"Unexpected error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
220
test_complaint_type_classification.py
Normal file
220
test_complaint_type_classification.py
Normal file
@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Test script to verify complaint vs appreciation classification.
|
||||
|
||||
This script tests:
|
||||
1. AI service complaint type detection
|
||||
2. Appreciation skipping SLA/PX Actions
|
||||
3. Classification with bilingual keywords
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, '/home/ismail/projects/HH')
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
from apps.core.ai_service import AIService
|
||||
from apps.organizations.models import Hospital, Department
|
||||
|
||||
def test_complaint_detection():
|
||||
"""Test AI detection of complaints vs appreciations"""
|
||||
print("\n" + "="*80)
|
||||
print("TESTING COMPLAINT vs APPRECIATION DETECTION")
|
||||
print("="*80)
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
'type': 'complaint',
|
||||
'description': "I am very disappointed with the service. The staff was rude and ignored me. I had to wait for 3 hours and nobody helped.",
|
||||
'expected': 'complaint'
|
||||
},
|
||||
{
|
||||
'type': 'complaint',
|
||||
'description': "The doctor was terrible. He didn't listen to my concerns and dismissed my symptoms. This is unacceptable.",
|
||||
'expected': 'complaint'
|
||||
},
|
||||
{
|
||||
'type': 'complaint',
|
||||
'description': "هناك مشكلة كبيرة في العيادة. الطبيب لم يستمع لي وتجاهل شكواي. خدمة سيئة جدا",
|
||||
'expected': 'complaint'
|
||||
},
|
||||
{
|
||||
'type': 'appreciation',
|
||||
'description': "I would like to thank Dr. Ahmed for the excellent care he provided. He was very professional and caring. The nurses were also wonderful.",
|
||||
'expected': 'appreciation'
|
||||
},
|
||||
{
|
||||
'type': 'appreciation',
|
||||
'description': "Great service! The staff was friendly and helpful. I appreciate the wonderful care I received. Thank you so much!",
|
||||
'expected': 'appreciation'
|
||||
},
|
||||
{
|
||||
'type': 'appreciation',
|
||||
'description': "أريد أن أشكر الطبيب محمد على الرعاية الممتازة. كان مهنيا جدا ومتعاطفا. شكرا جزيلا لكم",
|
||||
'expected': 'appreciation'
|
||||
},
|
||||
{
|
||||
'type': 'mixed',
|
||||
'description': "The doctor was good but the wait time was terrible. I appreciate the care but disappointed with the delay.",
|
||||
'expected': 'complaint' # Should default to complaint if mixed
|
||||
},
|
||||
]
|
||||
|
||||
correct = 0
|
||||
incorrect = 0
|
||||
|
||||
for i, test_case in enumerate(test_cases, 1):
|
||||
print(f"\nTest Case {i}: {test_case['type'].upper()}")
|
||||
print(f"Description: {test_case['description'][:100]}...")
|
||||
|
||||
# Detect type
|
||||
detected_type = AIService._detect_complaint_type(test_case['description'])
|
||||
|
||||
# Check result
|
||||
is_correct = detected_type == test_case['expected']
|
||||
status = "✓ CORRECT" if is_correct else "✗ INCORRECT"
|
||||
|
||||
if is_correct:
|
||||
correct += 1
|
||||
else:
|
||||
incorrect += 1
|
||||
|
||||
print(f"Expected: {test_case['expected']}")
|
||||
print(f"Detected: {detected_type}")
|
||||
print(f"Status: {status}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*80)
|
||||
print("CLASSIFICATION SUMMARY")
|
||||
print("="*80)
|
||||
print(f"Total Tests: {len(test_cases)}")
|
||||
print(f"Correct: {correct}")
|
||||
print(f"Incorrect: {incorrect}")
|
||||
print(f"Accuracy: {correct/len(test_cases)*100:.1f}%")
|
||||
print("="*80)
|
||||
|
||||
return correct == len(test_cases)
|
||||
|
||||
|
||||
def test_appreciation_workflow():
|
||||
"""Test that appreciations skip SLA/PX Actions"""
|
||||
print("\n" + "="*80)
|
||||
print("TESTING APPRECIATION WORKFLOW (Skip SLA/PX Actions)")
|
||||
print("="*80)
|
||||
|
||||
# Get a hospital
|
||||
try:
|
||||
hospital = Hospital.objects.first()
|
||||
if not hospital:
|
||||
print("✗ No hospital found in database")
|
||||
return False
|
||||
|
||||
print(f"Using hospital: {hospital.name}")
|
||||
except Exception as e:
|
||||
print(f"✗ Error getting hospital: {e}")
|
||||
return False
|
||||
|
||||
# Test appreciation analysis
|
||||
appreciation_text = "I want to thank the staff for the excellent care provided. Dr. Ahmed was wonderful and very professional."
|
||||
|
||||
print(f"\nAnalyzing appreciation text:")
|
||||
print(f"Text: {appreciation_text}")
|
||||
|
||||
analysis = AIService.analyze_complaint(
|
||||
title="Appreciation for excellent care",
|
||||
description=appreciation_text,
|
||||
hospital_id=hospital.id
|
||||
)
|
||||
|
||||
print(f"\nAI Analysis Results:")
|
||||
print(f"Complaint Type: {analysis.get('complaint_type', 'N/A')}")
|
||||
print(f"Severity: {analysis.get('severity', 'N/A')}")
|
||||
print(f"Priority: {analysis.get('priority', 'N/A')}")
|
||||
print(f"Category: {analysis.get('category', 'N/A')}")
|
||||
|
||||
# Check if detected as appreciation
|
||||
complaint_type = analysis.get('complaint_type', 'complaint')
|
||||
if complaint_type == 'appreciation':
|
||||
print(f"\n✓ Correctly identified as APPRECIATION")
|
||||
print(f"✓ SLA tracking and PX Actions should be SKIPPED")
|
||||
return True
|
||||
else:
|
||||
print(f"\n✗ Incorrectly identified as COMPLAINT")
|
||||
print(f"✗ This would incorrectly trigger SLA/PX Actions")
|
||||
return False
|
||||
|
||||
|
||||
def test_complaint_workflow():
|
||||
"""Test that complaints still work normally"""
|
||||
print("\n" + "="*80)
|
||||
print("TESTING COMPLAINT WORKFLOW (Normal SLA/PX Actions)")
|
||||
print("="*80)
|
||||
|
||||
# Get a hospital
|
||||
try:
|
||||
hospital = Hospital.objects.first()
|
||||
if not hospital:
|
||||
print("✗ No hospital found in database")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"✗ Error getting hospital: {e}")
|
||||
return False
|
||||
|
||||
# Test complaint analysis
|
||||
complaint_text = "The service was terrible. The staff was rude and ignored me for 2 hours."
|
||||
|
||||
print(f"\nAnalyzing complaint text:")
|
||||
print(f"Text: {complaint_text}")
|
||||
|
||||
analysis = AIService.analyze_complaint(
|
||||
title="Terrible service and rude staff",
|
||||
description=complaint_text,
|
||||
hospital_id=hospital.id
|
||||
)
|
||||
|
||||
print(f"\nAI Analysis Results:")
|
||||
print(f"Complaint Type: {analysis.get('complaint_type', 'N/A')}")
|
||||
print(f"Severity: {analysis.get('severity', 'N/A')}")
|
||||
print(f"Priority: {analysis.get('priority', 'N/A')}")
|
||||
print(f"Category: {analysis.get('category', 'N/A')}")
|
||||
|
||||
# Check if detected as complaint
|
||||
complaint_type = analysis.get('complaint_type', 'complaint')
|
||||
if complaint_type == 'complaint':
|
||||
print(f"\n✓ Correctly identified as COMPLAINT")
|
||||
print(f"✓ SLA tracking and PX Actions should be TRIGGERED")
|
||||
return True
|
||||
else:
|
||||
print(f"\n✗ Incorrectly identified as APPRECIATION")
|
||||
print(f"✗ This would incorrectly skip SLA/PX Actions")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("\n" + "="*80)
|
||||
print("COMPLAINT TYPE CLASSIFICATION TEST SUITE")
|
||||
print("="*80)
|
||||
|
||||
# Run all tests
|
||||
test1_passed = test_complaint_detection()
|
||||
test2_passed = test_appreciation_workflow()
|
||||
test3_passed = test_complaint_workflow()
|
||||
|
||||
# Final summary
|
||||
print("\n" + "="*80)
|
||||
print("FINAL TEST RESULTS")
|
||||
print("="*80)
|
||||
print(f"1. Classification Detection: {'✓ PASSED' if test1_passed else '✗ FAILED'}")
|
||||
print(f"2. Appreciation Workflow: {'✓ PASSED' if test2_passed else '✗ FAILED'}")
|
||||
print(f"3. Complaint Workflow: {'✓ PASSED' if test3_passed else '✗ FAILED'}")
|
||||
|
||||
all_passed = test1_passed and test2_passed and test3_passed
|
||||
print(f"\nOverall: {'✓ ALL TESTS PASSED' if all_passed else '✗ SOME TESTS FAILED'}")
|
||||
print("="*80 + "\n")
|
||||
|
||||
exit(0 if all_passed else 1)
|
||||
Loading…
x
Reference in New Issue
Block a user