HH/apps/analytics/services/analytics_service.py

1335 lines
51 KiB
Python

"""
Unified Analytics Service
Provides comprehensive analytics and metrics for the PX Command Center Dashboard.
Consolidates data from complaints, surveys, actions, physicians, and other modules.
"""
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from django.db.models import Avg, Count, Q, Sum, F, ExpressionWrapper, DurationField
from django.utils import timezone
from django.core.cache import cache
from apps.complaints.models import Complaint, Inquiry, ComplaintStatus
from apps.complaints.analytics import ComplaintAnalytics
from apps.px_action_center.models import PXAction
from apps.surveys.models import SurveyInstance
from apps.social.models import SocialMediaComment
from apps.callcenter.models import CallCenterInteraction
from apps.physicians.models import PhysicianMonthlyRating
from apps.organizations.models import Department, Hospital
from apps.ai_engine.models import SentimentResult
from apps.analytics.models import KPI, KPIValue
class UnifiedAnalyticsService:
"""
Unified service for all PX360 analytics and KPIs.
Provides methods to retrieve:
- All KPIs with filters
- Chart data for various visualizations
- Department performance metrics
- Physician analytics
- Sentiment analysis metrics
- SLA compliance data
"""
# Cache timeout (in seconds) - 5 minutes for most data
CACHE_TIMEOUT = 300
@staticmethod
def _get_cache_key(prefix: str, **kwargs) -> str:
"""Generate cache key based on parameters"""
parts = [prefix]
for key, value in sorted(kwargs.items()):
if value is not None:
parts.append(f"{key}:{value}")
return ":".join(parts)
@staticmethod
def _get_date_range(date_range: str, custom_start=None, custom_end=None) -> tuple:
"""
Get start and end dates based on date_range parameter.
Args:
date_range: '7d', '30d', '90d', 'this_month', 'last_month', 'quarter', 'year', or 'custom'
custom_start: Custom start date (required if date_range='custom')
custom_end: Custom end date (required if date_range='custom')
Returns:
tuple: (start_date, end_date)
"""
now = timezone.now()
if date_range == 'custom' and custom_start and custom_end:
return custom_start, custom_end
date_ranges = {
'7d': timedelta(days=7),
'30d': timedelta(days=30),
'90d': timedelta(days=90),
}
if date_range in date_ranges:
end_date = now
start_date = now - date_ranges[date_range]
return start_date, end_date
elif date_range == 'this_month':
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
return start_date, end_date
elif date_range == 'last_month':
if now.month == 1:
start_date = now.replace(year=now.year-1, month=12, day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now.replace(year=now.year-1, month=12, day=31, hour=23, minute=59, second=59)
else:
start_date = now.replace(month=now.month-1, day=1, hour=0, minute=0, second=0, microsecond=0)
# Get last day of previous month
next_month = now.replace(day=1)
last_day = (next_month - timedelta(days=1)).day
end_date = now.replace(month=now.month-1, day=last_day, hour=23, minute=59, second=59)
return start_date, end_date
elif date_range == 'quarter':
current_quarter = (now.month - 1) // 3
start_month = current_quarter * 3 + 1
start_date = now.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
return start_date, end_date
elif date_range == 'year':
start_date = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
return start_date, end_date
# Default to 30 days
return now - timedelta(days=30), now
@staticmethod
def _filter_by_role(queryset, user) -> Any:
"""
Filter queryset based on user role and permissions.
Args:
queryset: Django queryset
user: User object
Returns:
Filtered queryset
"""
# Check if queryset has hospital/department fields
if hasattr(queryset.model, 'hospital'):
if user.is_px_admin():
pass # See all
elif user.is_hospital_admin() and user.hospital:
queryset = queryset.filter(hospital=user.hospital)
elif user.is_department_manager() and user.department:
queryset = queryset.filter(department=user.department)
else:
queryset = queryset.none()
return queryset
@staticmethod
def get_all_kpis(
user,
date_range: str = '30d',
hospital_id: Optional[str] = None,
department_id: Optional[str] = None,
kpi_category: Optional[str] = None,
custom_start: Optional[datetime] = None,
custom_end: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Get all KPIs with applied filters.
Args:
user: Current user
date_range: Date range filter
hospital_id: Optional hospital filter
department_id: Optional department filter
kpi_category: Optional KPI category filter
custom_start: Custom start date
custom_end: Custom end date
Returns:
dict: All KPI values
"""
start_date, end_date = UnifiedAnalyticsService._get_date_range(
date_range, custom_start, custom_end
)
cache_key = UnifiedAnalyticsService._get_cache_key(
'all_kpis',
user_id=user.id,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
kpi_category=kpi_category
)
cached_data = cache.get(cache_key)
if cached_data:
return cached_data
# Get base querysets with role filtering
complaints_qs = UnifiedAnalyticsService._filter_by_role(
Complaint.objects.all(), user
).filter(created_at__gte=start_date, created_at__lte=end_date)
actions_qs = UnifiedAnalyticsService._filter_by_role(
PXAction.objects.all(), user
).filter(created_at__gte=start_date, created_at__lte=end_date)
surveys_qs = UnifiedAnalyticsService._filter_by_role(
SurveyInstance.objects.all(), user
).filter(
completed_at__gte=start_date,
completed_at__lte=end_date,
status='completed'
)
# Apply additional filters
if hospital_id:
hospital = Hospital.objects.filter(id=hospital_id).first()
if hospital:
complaints_qs = complaints_qs.filter(hospital=hospital)
actions_qs = actions_qs.filter(hospital=hospital)
surveys_qs = surveys_qs.filter(survey_template__hospital=hospital)
if department_id:
department = Department.objects.filter(id=department_id).first()
if department:
complaints_qs = complaints_qs.filter(department=department)
actions_qs = actions_qs.filter(department=department)
surveys_qs = surveys_qs.filter(journey_stage_instance__department=department)
# Calculate KPIs
kpis = {
# Complaints KPIs
'total_complaints': int(complaints_qs.count()),
'open_complaints': int(complaints_qs.filter(status__in=['open', 'in_progress']).count()),
'overdue_complaints': int(complaints_qs.filter(is_overdue=True).count()),
'high_severity_complaints': int(complaints_qs.filter(severity__in=['high', 'critical']).count()),
'resolved_complaints': int(complaints_qs.filter(status__in=['resolved', 'closed']).count()),
# Actions KPIs
'total_actions': int(actions_qs.count()),
'open_actions': int(actions_qs.filter(status__in=['open', 'in_progress']).count()),
'overdue_actions': int(actions_qs.filter(is_overdue=True).count()),
'escalated_actions': int(actions_qs.filter(escalation_level__gt=0).count()),
'resolved_actions': int(actions_qs.filter(status='completed').count()),
# Survey KPIs
'total_surveys': int(surveys_qs.count()),
'negative_surveys': int(surveys_qs.filter(is_negative=True).count()),
'avg_survey_score': float(surveys_qs.aggregate(avg=Avg('total_score'))['avg'] or 0),
# Social Media KPIs
# Sentiment is stored in ai_analysis JSON field as ai_analysis.sentiment
'negative_social_comments': int(SocialMediaComment.objects.filter(
ai_analysis__sentiment='negative',
published_at__gte=start_date,
published_at__lte=end_date
).count()),
# Call Center KPIs
'low_call_ratings': int(CallCenterInteraction.objects.filter(
is_low_rating=True,
call_started_at__gte=start_date,
call_started_at__lte=end_date
).count()),
# Sentiment KPIs
'total_sentiment_analyses': int(SentimentResult.objects.filter(
created_at__gte=start_date,
created_at__lte=end_date
).count()),
}
# Add trends (compare with previous period)
prev_start, prev_end = UnifiedAnalyticsService._get_date_range(
date_range, custom_start, custom_end
)
# Shift back by same duration
duration = end_date - start_date
prev_start = start_date - duration
prev_end = end_date - duration
prev_complaints = int(complaints_qs.filter(
created_at__gte=prev_start,
created_at__lte=prev_end
).count())
kpis['complaints_trend'] = {
'current': kpis['total_complaints'],
'previous': prev_complaints,
'percentage_change': float(
((kpis['total_complaints'] - prev_complaints) / prev_complaints * 100)
if prev_complaints > 0 else 0
)
}
# Cache the results
cache.set(cache_key, kpis, UnifiedAnalyticsService.CACHE_TIMEOUT)
return kpis
@staticmethod
def get_chart_data(
user,
chart_type: str,
date_range: str = '30d',
hospital_id: Optional[str] = None,
department_id: Optional[str] = None,
custom_start: Optional[datetime] = None,
custom_end: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Get data for specific chart types.
Args:
user: Current user
chart_type: Type of chart ('complaints_trend', 'sla_compliance', 'survey_satisfaction', etc.)
date_range: Date range filter
hospital_id: Optional hospital filter
department_id: Optional department filter
custom_start: Custom start date
custom_end: Custom end date
Returns:
dict: Chart data in format suitable for ApexCharts
"""
start_date, end_date = UnifiedAnalyticsService._get_date_range(
date_range, custom_start, custom_end
)
cache_key = UnifiedAnalyticsService._get_cache_key(
f'chart_{chart_type}',
user_id=user.id,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id
)
cached_data = cache.get(cache_key)
if cached_data:
return cached_data
# Get base complaint queryset
complaints_qs = UnifiedAnalyticsService._filter_by_role(
Complaint.objects.all(), user
).filter(created_at__gte=start_date, created_at__lte=end_date)
surveys_qs = UnifiedAnalyticsService._filter_by_role(
SurveyInstance.objects.all(), user
).filter(
completed_at__gte=start_date,
completed_at__lte=end_date,
status='completed'
)
# Apply filters
if hospital_id:
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
surveys_qs = surveys_qs.filter(survey_template__hospital_id=hospital_id)
if department_id:
complaints_qs = complaints_qs.filter(department_id=department_id)
surveys_qs = surveys_qs.filter(journey_stage_instance__department_id=department_id)
if chart_type == 'complaints_trend':
data = UnifiedAnalyticsService._get_complaints_trend(complaints_qs, start_date, end_date)
elif chart_type == 'complaints_by_category':
data = UnifiedAnalyticsService._get_complaints_by_category(complaints_qs)
elif chart_type == 'complaints_by_severity':
data = UnifiedAnalyticsService._get_complaints_by_severity(complaints_qs)
elif chart_type == 'sla_compliance':
data = ComplaintAnalytics.get_sla_compliance(
hospital_id and Hospital.objects.filter(id=hospital_id).first(),
days=(end_date - start_date).days
)
elif chart_type == 'resolution_rate':
data = ComplaintAnalytics.get_resolution_rate(
hospital_id and Hospital.objects.filter(id=hospital_id).first(),
days=(end_date - start_date).days
)
elif chart_type == 'survey_satisfaction_trend':
data = UnifiedAnalyticsService._get_survey_satisfaction_trend(surveys_qs, start_date, end_date)
elif chart_type == 'survey_distribution':
data = UnifiedAnalyticsService._get_survey_distribution(surveys_qs)
elif chart_type == 'sentiment_distribution':
data = UnifiedAnalyticsService._get_sentiment_distribution(start_date, end_date)
elif chart_type == 'department_performance':
data = UnifiedAnalyticsService._get_department_performance(
user, start_date, end_date, hospital_id
)
elif chart_type == 'physician_leaderboard':
data = UnifiedAnalyticsService._get_physician_leaderboard(
user, start_date, end_date, hospital_id, department_id, limit=10
)
else:
data = {'error': f'Unknown chart type: {chart_type}'}
cache.set(cache_key, data, UnifiedAnalyticsService.CACHE_TIMEOUT)
return data
@staticmethod
def _get_complaints_trend(queryset, start_date, end_date) -> Dict[str, Any]:
"""Get complaints trend over time (grouped by day)"""
data = []
current_date = start_date
while current_date <= end_date:
next_date = current_date + timedelta(days=1)
count = queryset.filter(
created_at__gte=current_date,
created_at__lt=next_date
).count()
data.append({
'date': current_date.strftime('%Y-%m-%d'),
'count': count
})
current_date = next_date
return {
'type': 'line',
'labels': [d['date'] for d in data],
'series': [{'name': 'Complaints', 'data': [d['count'] for d in data]}]
}
@staticmethod
def _get_complaints_by_category(queryset) -> Dict[str, Any]:
"""Get complaints breakdown by category"""
categories = queryset.values('category').annotate(
count=Count('id')
).order_by('-count')
return {
'type': 'donut',
'labels': [c['category'] or 'Uncategorized' for c in categories],
'series': [c['count'] for c in categories]
}
@staticmethod
def _get_complaints_by_severity(queryset) -> Dict[str, Any]:
"""Get complaints breakdown by severity"""
severity_counts = queryset.values('severity').annotate(
count=Count('id')
).order_by('-count')
severity_labels = {
'low': 'Low',
'medium': 'Medium',
'high': 'High',
'critical': 'Critical'
}
return {
'type': 'pie',
'labels': [severity_labels.get(s['severity'], s['severity']) for s in severity_counts],
'series': [s['count'] for s in severity_counts]
}
@staticmethod
def _get_survey_satisfaction_trend(queryset, start_date, end_date) -> Dict[str, Any]:
"""Get survey satisfaction trend over time"""
data = []
current_date = start_date
while current_date <= end_date:
next_date = current_date + timedelta(days=1)
avg_score = queryset.filter(
completed_at__gte=current_date,
completed_at__lt=next_date
).aggregate(avg=Avg('total_score'))['avg'] or 0
data.append({
'date': current_date.strftime('%Y-%m-%d'),
'score': round(avg_score, 2)
})
current_date = next_date
return {
'type': 'line',
'labels': [d['date'] for d in data],
'series': [{'name': 'Satisfaction', 'data': [d['score'] for d in data]}]
}
@staticmethod
def _get_survey_distribution(queryset) -> Dict[str, Any]:
"""Get survey distribution by satisfaction level"""
distribution = {
'excellent': queryset.filter(total_score__gte=4.5).count(),
'good': queryset.filter(total_score__gte=3.5, total_score__lt=4.5).count(),
'average': queryset.filter(total_score__gte=2.5, total_score__lt=3.5).count(),
'poor': queryset.filter(total_score__lt=2.5).count(),
}
return {
'type': 'donut',
'labels': ['Excellent', 'Good', 'Average', 'Poor'],
'series': [
distribution['excellent'],
distribution['good'],
distribution['average'],
distribution['poor']
]
}
@staticmethod
def get_staff_performance_metrics(
user,
date_range: str = '30d',
hospital_id: Optional[str] = None,
department_id: Optional[str] = None,
staff_ids: Optional[List[str]] = None,
custom_start: Optional[datetime] = None,
custom_end: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Get performance metrics for staff members.
Args:
user: Current user
date_range: Date range filter
hospital_id: Optional hospital filter
department_id: Optional department filter
staff_ids: Optional list of specific staff IDs to evaluate
custom_start: Custom start date
custom_end: Custom end date
Returns:
dict: Staff performance metrics with complaints and inquiries data
"""
from apps.accounts.models import User
start_date, end_date = UnifiedAnalyticsService._get_date_range(
date_range, custom_start, custom_end
)
# Get staff queryset
staff_qs = User.objects.all()
# Filter by role
if not user.is_px_admin() and user.hospital:
staff_qs = staff_qs.filter(hospital=user.hospital)
# Apply filters
if hospital_id:
staff_qs = staff_qs.filter(hospital_id=hospital_id)
if department_id:
staff_qs = staff_qs.filter(department_id=department_id)
if staff_ids:
staff_qs = staff_qs.filter(id__in=staff_ids)
# Only staff with assigned complaints or inquiries
staff_qs = staff_qs.filter(
Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False)
).distinct().prefetch_related('assigned_complaints', 'assigned_inquiries')
staff_metrics = []
for staff_member in staff_qs:
# Get complaints assigned to this staff
complaints = Complaint.objects.filter(
assigned_to=staff_member,
created_at__gte=start_date,
created_at__lte=end_date
)
# Get inquiries assigned to this staff
inquiries = Inquiry.objects.filter(
assigned_to=staff_member,
created_at__gte=start_date,
created_at__lte=end_date
)
# Calculate complaint metrics
complaint_metrics = UnifiedAnalyticsService._calculate_complaint_metrics(complaints)
# Calculate inquiry metrics
inquiry_metrics = UnifiedAnalyticsService._calculate_inquiry_metrics(inquiries)
staff_metrics.append({
'id': str(staff_member.id),
'name': f"{staff_member.first_name} {staff_member.last_name}",
'email': staff_member.email,
'hospital': staff_member.hospital.name if staff_member.hospital else None,
'department': staff_member.department.name if staff_member.department else None,
'complaints': complaint_metrics,
'inquiries': inquiry_metrics
})
return {
'staff_metrics': staff_metrics,
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'date_range': date_range
}
@staticmethod
def _calculate_complaint_metrics(complaints_qs) -> Dict[str, Any]:
"""Calculate detailed metrics for complaints"""
total = complaints_qs.count()
if total == 0:
return {
'total': 0,
'internal': 0,
'external': 0,
'status': {'open': 0, 'in_progress': 0, 'resolved': 0, 'closed': 0},
'activation_time': {'within_2h': 0, 'more_than_2h': 0, 'not_assigned': 0},
'response_time': {'within_24h': 0, 'within_48h': 0, 'within_72h': 0, 'more_than_72h': 0, 'not_responded': 0}
}
# Source breakdown
internal_count = complaints_qs.filter(source__name_en='staff').count()
external_count = total - internal_count
# Status breakdown
status_counts = {
'open': complaints_qs.filter(status='open').count(),
'in_progress': complaints_qs.filter(status='in_progress').count(),
'resolved': complaints_qs.filter(status='resolved').count(),
'closed': complaints_qs.filter(status='closed').count()
}
# Activation time (assigned_at - created_at)
activation_within_2h = 0
activation_more_than_2h = 0
not_assigned = 0
for complaint in complaints_qs:
if complaint.assigned_at:
activation_time = (complaint.assigned_at - complaint.created_at).total_seconds()
if activation_time <= 7200: # 2 hours
activation_within_2h += 1
else:
activation_more_than_2h += 1
else:
not_assigned += 1
# Response time (time to first update)
response_within_24h = 0
response_within_48h = 0
response_within_72h = 0
response_more_than_72h = 0
not_responded = 0
for complaint in complaints_qs:
first_update = complaint.updates.first()
if first_update:
response_time = (first_update.created_at - complaint.created_at).total_seconds()
if response_time <= 86400: # 24 hours
response_within_24h += 1
elif response_time <= 172800: # 48 hours
response_within_48h += 1
elif response_time <= 259200: # 72 hours
response_within_72h += 1
else:
response_more_than_72h += 1
else:
not_responded += 1
return {
'total': total,
'internal': internal_count,
'external': external_count,
'status': status_counts,
'activation_time': {
'within_2h': activation_within_2h,
'more_than_2h': activation_more_than_2h,
'not_assigned': not_assigned
},
'response_time': {
'within_24h': response_within_24h,
'within_48h': response_within_48h,
'within_72h': response_within_72h,
'more_than_72h': response_more_than_72h,
'not_responded': not_responded
}
}
@staticmethod
def _calculate_inquiry_metrics(inquiries_qs) -> Dict[str, Any]:
"""Calculate detailed metrics for inquiries"""
total = inquiries_qs.count()
if total == 0:
return {
'total': 0,
'status': {'open': 0, 'in_progress': 0, 'resolved': 0, 'closed': 0},
'response_time': {'within_24h': 0, 'within_48h': 0, 'within_72h': 0, 'more_than_72h': 0, 'not_responded': 0}
}
# Status breakdown
status_counts = {
'open': inquiries_qs.filter(status='open').count(),
'in_progress': inquiries_qs.filter(status='in_progress').count(),
'resolved': inquiries_qs.filter(status='resolved').count(),
'closed': inquiries_qs.filter(status='closed').count()
}
# Response time (responded_at - created_at)
response_within_24h = 0
response_within_48h = 0
response_within_72h = 0
response_more_than_72h = 0
not_responded = 0
for inquiry in inquiries_qs:
if inquiry.responded_at:
response_time = (inquiry.responded_at - inquiry.created_at).total_seconds()
if response_time <= 86400: # 24 hours
response_within_24h += 1
elif response_time <= 172800: # 48 hours
response_within_48h += 1
elif response_time <= 259200: # 72 hours
response_within_72h += 1
else:
response_more_than_72h += 1
else:
not_responded += 1
return {
'total': total,
'status': status_counts,
'response_time': {
'within_24h': response_within_24h,
'within_48h': response_within_48h,
'within_72h': response_within_72h,
'more_than_72h': response_more_than_72h,
'not_responded': not_responded
}
}
@staticmethod
def _get_sentiment_distribution(start_date, end_date) -> Dict[str, Any]:
"""Get sentiment analysis distribution"""
queryset = SentimentResult.objects.filter(
created_at__gte=start_date,
created_at__lte=end_date
)
distribution = queryset.values('sentiment').annotate(
count=Count('id')
)
sentiment_labels = {
'positive': 'Positive',
'neutral': 'Neutral',
'negative': 'Negative'
}
sentiment_order = ['positive', 'neutral', 'negative']
return {
'type': 'donut',
'labels': [sentiment_labels.get(s['sentiment'], s['sentiment']) for s in distribution],
'series': [s['count'] for s in distribution]
}
@staticmethod
def _get_department_performance(
user, start_date, end_date, hospital_id: Optional[str] = None
) -> Dict[str, Any]:
"""Get department performance rankings"""
queryset = Department.objects.filter(status='active')
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
elif not user.is_px_admin() and user.hospital:
queryset = queryset.filter(hospital=user.hospital)
# Annotate with survey data
# SurveyInstance links to PatientJourneyInstance which has department field
departments = queryset.annotate(
avg_survey_score=Avg(
'journey_instances__surveys__total_score',
filter=Q(journey_instances__surveys__status='completed',
journey_instances__surveys__completed_at__gte=start_date,
journey_instances__surveys__completed_at__lte=end_date)
),
survey_count=Count(
'journey_instances__surveys',
filter=Q(journey_instances__surveys__status='completed',
journey_instances__surveys__completed_at__gte=start_date,
journey_instances__surveys__completed_at__lte=end_date)
)
).filter(survey_count__gt=0).order_by('-avg_survey_score')[:10]
return {
'type': 'bar',
'labels': [d.name for d in departments],
'series': [{
'name': 'Average Score',
'data': [round(d.avg_survey_score or 0, 2) for d in departments]
}]
}
@staticmethod
def _get_physician_leaderboard(
user, start_date, end_date, hospital_id: Optional[str] = None,
department_id: Optional[str] = None, limit: int = 10
) -> Dict[str, Any]:
"""Get physician leaderboard for the current period"""
now = timezone.now()
queryset = PhysicianMonthlyRating.objects.filter(
year=now.year,
month=now.month
).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters
if not user.is_px_admin() and user.hospital:
queryset = queryset.filter(staff__hospital=user.hospital)
if hospital_id:
queryset = queryset.filter(staff__hospital_id=hospital_id)
if department_id:
queryset = queryset.filter(staff__department_id=department_id)
queryset = queryset.order_by('-average_rating')[:limit]
return {
'type': 'bar',
'labels': [f"{r.staff.first_name} {r.staff.last_name}" for r in queryset],
'series': [{
'name': 'Rating',
'data': [float(round(r.average_rating, 2)) for r in queryset]
}],
'metadata': [
{
'name': f"{r.staff.first_name} {r.staff.last_name}",
'physician_id': str(r.staff.id),
'specialization': r.staff.specialization,
'department': r.staff.department.name if r.staff.department else None,
'rating': float(round(r.average_rating, 2)),
'surveys': int(r.total_surveys) if r.total_surveys is not None else 0,
'positive': int(r.positive_count) if r.positive_count is not None else 0,
'neutral': int(r.neutral_count) if r.neutral_count is not None else 0,
'negative': int(r.negative_count) if r.negative_count is not None else 0
}
for r in queryset
]
}
# ============================================================================
# ENHANCED ADMIN EVALUATION - Staff Performance Analytics
# ============================================================================
@staticmethod
def get_staff_detailed_performance(
staff_id: str,
user,
date_range: str = '30d',
custom_start: Optional[datetime] = None,
custom_end: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Get detailed performance metrics for a single staff member.
Args:
staff_id: Staff member UUID
user: Current user (for permission checking)
date_range: Date range filter
custom_start: Custom start date
custom_end: Custom end date
Returns:
dict: Detailed performance metrics with timeline
"""
from apps.accounts.models import User
start_date, end_date = UnifiedAnalyticsService._get_date_range(
date_range, custom_start, custom_end
)
staff = User.objects.select_related('hospital', 'department').get(id=staff_id)
# Check permissions
if not user.is_px_admin():
if user.hospital and staff.hospital != user.hospital:
raise PermissionError("Cannot view staff from other hospitals")
# Get complaints with timeline
complaints = Complaint.objects.filter(
assigned_to=staff,
created_at__gte=start_date,
created_at__lte=end_date
).order_by('created_at')
# Get inquiries with timeline
inquiries = Inquiry.objects.filter(
assigned_to=staff,
created_at__gte=start_date,
created_at__lte=end_date
).order_by('created_at')
# Calculate daily workload for trend
daily_stats = {}
current = start_date.date()
end = end_date.date()
while current <= end:
daily_stats[current.isoformat()] = {
'complaints_created': 0,
'complaints_resolved': 0,
'inquiries_created': 0,
'inquiries_resolved': 0
}
current += timedelta(days=1)
for c in complaints:
date_key = c.created_at.date().isoformat()
if date_key in daily_stats:
daily_stats[date_key]['complaints_created'] += 1
if c.status in ['resolved', 'closed'] and c.resolved_at:
resolve_key = c.resolved_at.date().isoformat()
if resolve_key in daily_stats:
daily_stats[resolve_key]['complaints_resolved'] += 1
for i in inquiries:
date_key = i.created_at.date().isoformat()
if date_key in daily_stats:
daily_stats[date_key]['inquiries_created'] += 1
if i.status in ['resolved', 'closed'] and i.responded_at:
respond_key = i.responded_at.date().isoformat()
if respond_key in daily_stats:
daily_stats[respond_key]['inquiries_resolved'] += 1
# Calculate performance score (0-100)
complaint_metrics = UnifiedAnalyticsService._calculate_complaint_metrics(complaints)
inquiry_metrics = UnifiedAnalyticsService._calculate_inquiry_metrics(inquiries)
performance_score = UnifiedAnalyticsService._calculate_performance_score(
complaint_metrics, inquiry_metrics
)
# Get recent items
recent_complaints = complaints.select_related('patient', 'hospital').order_by('-created_at')[:10]
recent_inquiries = inquiries.select_related('patient', 'hospital').order_by('-created_at')[:10]
return {
'staff': {
'id': str(staff.id),
'name': f"{staff.first_name} {staff.last_name}",
'email': staff.email,
'hospital': staff.hospital.name if staff.hospital else None,
'department': staff.department.name if staff.department else None,
'role': staff.get_role_names()[0] if staff.get_role_names() else 'Staff'
},
'performance_score': performance_score,
'period': {
'start': start_date.isoformat(),
'end': end_date.isoformat(),
'days': (end_date - start_date).days
},
'summary': {
'total_complaints': complaint_metrics['total'],
'total_inquiries': inquiry_metrics['total'],
'complaint_resolution_rate': round(
(complaint_metrics['status']['resolved'] + complaint_metrics['status']['closed']) /
max(complaint_metrics['total'], 1) * 100, 1
),
'inquiry_resolution_rate': round(
(inquiry_metrics['status']['resolved'] + inquiry_metrics['status']['closed']) /
max(inquiry_metrics['total'], 1) * 100, 1
)
},
'complaint_metrics': complaint_metrics,
'inquiry_metrics': inquiry_metrics,
'daily_trends': daily_stats,
'recent_complaints': [
{
'id': str(c.id),
'title': c.title,
'status': c.status,
'severity': c.severity,
'created_at': c.created_at.isoformat(),
'patient': c.patient.get_full_name() if c.patient else None
}
for c in recent_complaints
],
'recent_inquiries': [
{
'id': str(i.id),
'subject': i.subject,
'status': i.status,
'created_at': i.created_at.isoformat(),
'patient': i.patient.get_full_name() if i.patient else None
}
for i in recent_inquiries
]
}
@staticmethod
def _calculate_performance_score(complaint_metrics: Dict, inquiry_metrics: Dict) -> Dict[str, Any]:
"""
Calculate an overall performance score (0-100) based on multiple factors.
Returns score breakdown and overall rating.
"""
scores = {
'complaint_resolution': 0,
'complaint_response_time': 0,
'complaint_activation_time': 0,
'inquiry_resolution': 0,
'inquiry_response_time': 0,
'workload': 0
}
total_complaints = complaint_metrics['total']
total_inquiries = inquiry_metrics['total']
if total_complaints > 0:
# Resolution score (40% weight)
resolved = complaint_metrics['status']['resolved'] + complaint_metrics['status']['closed']
scores['complaint_resolution'] = min(100, (resolved / total_complaints) * 100)
# Response time score (20% weight)
response = complaint_metrics['response_time']
on_time = response['within_24h'] + response['within_48h']
total_with_response = on_time + response['within_72h'] + response['more_than_72h']
if total_with_response > 0:
scores['complaint_response_time'] = min(100, (on_time / total_with_response) * 100)
# Activation time score (10% weight)
activation = complaint_metrics['activation_time']
if activation['within_2h'] + activation['more_than_2h'] > 0:
scores['complaint_activation_time'] = min(100,
(activation['within_2h'] / (activation['within_2h'] + activation['more_than_2h'])) * 100
)
if total_inquiries > 0:
# Resolution score (15% weight)
resolved = inquiry_metrics['status']['resolved'] + inquiry_metrics['status']['closed']
scores['inquiry_resolution'] = min(100, (resolved / total_inquiries) * 100)
# Response time score (10% weight)
response = inquiry_metrics['response_time']
on_time = response['within_24h'] + response['within_48h']
total_with_response = on_time + response['within_72h'] + response['more_than_72h']
if total_with_response > 0:
scores['inquiry_response_time'] = min(100, (on_time / total_with_response) * 100)
# Workload score based on having reasonable volume (5% weight)
total_items = total_complaints + total_inquiries
if total_items >= 5:
scores['workload'] = 100
elif total_items > 0:
scores['workload'] = (total_items / 5) * 100
# Calculate weighted overall score
weights = {
'complaint_resolution': 0.25,
'complaint_response_time': 0.15,
'complaint_activation_time': 0.10,
'inquiry_resolution': 0.20,
'inquiry_response_time': 0.15,
'workload': 0.15
}
overall_score = sum(scores[k] * weights[k] for k in scores)
# Determine rating
if overall_score >= 90:
rating = 'Excellent'
rating_color = 'success'
elif overall_score >= 75:
rating = 'Good'
rating_color = 'info'
elif overall_score >= 60:
rating = 'Average'
rating_color = 'warning'
elif overall_score >= 40:
rating = 'Below Average'
rating_color = 'danger'
else:
rating = 'Needs Improvement'
rating_color = 'dark'
return {
'overall': round(overall_score, 1),
'breakdown': scores,
'rating': rating,
'rating_color': rating_color,
'total_items_handled': total_complaints + total_inquiries
}
@staticmethod
def get_staff_performance_trends(
staff_id: str,
user,
months: int = 6
) -> List[Dict[str, Any]]:
"""
Get monthly performance trends for a staff member.
Args:
staff_id: Staff member UUID
user: Current user
months: Number of months to look back
Returns:
list: Monthly performance data
"""
from apps.accounts.models import User
staff = User.objects.get(id=staff_id)
# Check permissions
if not user.is_px_admin():
if user.hospital and staff.hospital != user.hospital:
raise PermissionError("Cannot view staff from other hospitals")
trends = []
now = timezone.now()
for i in range(months - 1, -1, -1):
# Calculate month
month_date = now - timedelta(days=i * 30)
month_start = month_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if month_date.month == 12:
month_end = month_date.replace(year=month_date.year + 1, month=1, day=1) - timedelta(seconds=1)
else:
month_end = month_date.replace(month=month_date.month + 1, day=1) - timedelta(seconds=1)
# Get complaints for this month
complaints = Complaint.objects.filter(
assigned_to=staff,
created_at__gte=month_start,
created_at__lte=month_end
)
# Get inquiries for this month
inquiries = Inquiry.objects.filter(
assigned_to=staff,
created_at__gte=month_start,
created_at__lte=month_end
)
complaint_metrics = UnifiedAnalyticsService._calculate_complaint_metrics(complaints)
inquiry_metrics = UnifiedAnalyticsService._calculate_inquiry_metrics(inquiries)
score_data = UnifiedAnalyticsService._calculate_performance_score(
complaint_metrics, inquiry_metrics
)
trends.append({
'month': month_start.strftime('%Y-%m'),
'month_name': month_start.strftime('%b %Y'),
'performance_score': score_data['overall'],
'rating': score_data['rating'],
'complaints_total': complaint_metrics['total'],
'complaints_resolved': complaint_metrics['status']['resolved'] + complaint_metrics['status']['closed'],
'inquiries_total': inquiry_metrics['total'],
'inquiries_resolved': inquiry_metrics['status']['resolved'] + inquiry_metrics['status']['closed']
})
return trends
@staticmethod
def get_department_benchmarks(
user,
department_id: Optional[str] = None,
date_range: str = '30d',
custom_start: Optional[datetime] = None,
custom_end: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Get benchmarking data comparing staff within a department.
Args:
user: Current user
department_id: Optional department filter
date_range: Date range filter
custom_start: Custom start date
custom_end: Custom end date
Returns:
dict: Benchmarking metrics
"""
from apps.accounts.models import User
from apps.organizations.models import Department
start_date, end_date = UnifiedAnalyticsService._get_date_range(
date_range, custom_start, custom_end
)
# Get department
if department_id:
department = Department.objects.get(id=department_id)
elif user.department:
department = user.department
else:
return {'error': 'No department specified'}
# Get all staff in department
staff_qs = User.objects.filter(
department=department,
is_active=True
).filter(
Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False)
).distinct()
staff_scores = []
for staff in staff_qs:
complaints = Complaint.objects.filter(
assigned_to=staff,
created_at__gte=start_date,
created_at__lte=end_date
)
inquiries = Inquiry.objects.filter(
assigned_to=staff,
created_at__gte=start_date,
created_at__lte=end_date
)
complaint_metrics = UnifiedAnalyticsService._calculate_complaint_metrics(complaints)
inquiry_metrics = UnifiedAnalyticsService._calculate_inquiry_metrics(inquiries)
score_data = UnifiedAnalyticsService._calculate_performance_score(
complaint_metrics, inquiry_metrics
)
staff_scores.append({
'id': str(staff.id),
'name': f"{staff.first_name} {staff.last_name}",
'score': score_data['overall'],
'rating': score_data['rating'],
'total_items': score_data['total_items_handled'],
'complaints': complaint_metrics['total'],
'inquiries': inquiry_metrics['total']
})
# Sort by score
staff_scores.sort(key=lambda x: x['score'], reverse=True)
# Calculate averages
if staff_scores:
avg_score = sum(s['score'] for s in staff_scores) / len(staff_scores)
avg_items = sum(s['total_items'] for s in staff_scores) / len(staff_scores)
else:
avg_score = 0
avg_items = 0
return {
'department': department.name,
'period': {
'start': start_date.isoformat(),
'end': end_date.isoformat()
},
'staff_count': len(staff_scores),
'average_score': round(avg_score, 1),
'average_items_per_staff': round(avg_items, 1),
'top_performer': staff_scores[0] if staff_scores else None,
'needs_improvement': [s for s in staff_scores if s['score'] < 60],
'rankings': staff_scores
}
@staticmethod
def export_staff_performance_report(
staff_ids: List[str],
user,
date_range: str = '30d',
custom_start: Optional[datetime] = None,
custom_end: Optional[datetime] = None,
format_type: str = 'csv'
) -> Dict[str, Any]:
"""
Generate exportable staff performance report.
Args:
staff_ids: List of staff UUIDs to include
user: Current user
date_range: Date range filter
custom_start: Custom start date
custom_end: Custom end date
format_type: Export format ('csv', 'excel', 'json')
Returns:
dict: Report data and metadata
"""
start_date, end_date = UnifiedAnalyticsService._get_date_range(
date_range, custom_start, custom_end
)
# Get performance data
performance_data = UnifiedAnalyticsService.get_staff_performance_metrics(
user=user,
date_range=date_range,
staff_ids=staff_ids if staff_ids else None,
custom_start=custom_start,
custom_end=custom_end
)
# Format for export
export_rows = []
for staff in performance_data['staff_metrics']:
c = staff['complaints']
i = staff['inquiries']
# Calculate additional metrics
complaint_resolution_rate = 0
if c['total'] > 0:
complaint_resolution_rate = round(
(c['status']['resolved'] + c['status']['closed']) / c['total'] * 100, 1
)
inquiry_resolution_rate = 0
if i['total'] > 0:
inquiry_resolution_rate = round(
(i['status']['resolved'] + i['status']['closed']) / i['total'] * 100, 1
)
export_rows.append({
'staff_name': staff['name'],
'email': staff['email'],
'hospital': staff['hospital'],
'department': staff['department'],
'complaints_total': c['total'],
'complaints_internal': c['internal'],
'complaints_external': c['external'],
'complaints_open': c['status']['open'],
'complaints_resolved': c['status']['resolved'],
'complaints_closed': c['status']['closed'],
'complaint_resolution_rate': f"{complaint_resolution_rate}%",
'complaint_activation_within_2h': c['activation_time']['within_2h'],
'complaint_response_within_24h': c['response_time']['within_24h'],
'inquiries_total': i['total'],
'inquiries_open': i['status']['open'],
'inquiries_resolved': i['status']['resolved'],
'inquiry_resolution_rate': f"{inquiry_resolution_rate}%",
'inquiry_response_within_24h': i['response_time']['within_24h']
})
return {
'format': format_type,
'generated_at': timezone.now().isoformat(),
'period': {
'start': start_date.isoformat(),
'end': end_date.isoformat()
},
'total_staff': len(export_rows),
'data': export_rows
}