2400 lines
94 KiB
Python
2400 lines
94 KiB
Python
"""
|
||
Survey analytics utilities.
|
||
|
||
This module provides reusable functions and classes for survey analytics:
|
||
- Statistical analysis (correlation, skewness, kurtosis)
|
||
- Analytics generation
|
||
- Report generation (Markdown, HTML, JSON)
|
||
|
||
These utilities can be used by:
|
||
- Management commands (CLI)
|
||
- API views (REST)
|
||
- Other services
|
||
"""
|
||
import json
|
||
import math
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
from django.db.models import Avg, Count, StdDev, Q, F, Sum, ExpressionWrapper, IntegerField
|
||
from django.utils import timezone
|
||
from django.conf import settings
|
||
|
||
from apps.surveys.models import SurveyTemplate, SurveyInstance, SurveyResponse, SurveyQuestion
|
||
|
||
|
||
class StatisticalAnalyzer:
|
||
"""Statistical analysis helper class"""
|
||
|
||
@staticmethod
|
||
def calculate_mean(values):
|
||
"""Calculate mean of values"""
|
||
if not values:
|
||
return 0.0
|
||
return sum(values) / len(values)
|
||
|
||
@staticmethod
|
||
def calculate_stddev(values, mean=None):
|
||
"""Calculate standard deviation"""
|
||
if not values:
|
||
return 0.0
|
||
if mean is None:
|
||
mean = StatisticalAnalyzer.calculate_mean(values)
|
||
variance = sum((x - mean) ** 2 for x in values) / len(values)
|
||
return math.sqrt(variance)
|
||
|
||
@staticmethod
|
||
def calculate_skewness(values, mean=None, stddev=None):
|
||
"""Calculate skewness (measure of asymmetry)"""
|
||
if not values or len(values) < 3:
|
||
return 0.0
|
||
if mean is None:
|
||
mean = StatisticalAnalyzer.calculate_mean(values)
|
||
if stddev is None:
|
||
stddev = StatisticalAnalyzer.calculate_stddev(values, mean)
|
||
if stddev == 0:
|
||
return 0.0
|
||
|
||
n = len(values)
|
||
skew = (n / ((n - 1) * (n - 2) * stddev ** 3)) * \
|
||
sum((x - mean) ** 3 for x in values)
|
||
return skew
|
||
|
||
@staticmethod
|
||
def calculate_kurtosis(values, mean=None, stddev=None):
|
||
"""Calculate kurtosis (measure of tail heaviness)"""
|
||
if not values or len(values) < 4:
|
||
return 0.0
|
||
if mean is None:
|
||
mean = StatisticalAnalyzer.calculate_mean(values)
|
||
if stddev is None:
|
||
stddev = StatisticalAnalyzer.calculate_stddev(values, mean)
|
||
if stddev == 0:
|
||
return 0.0
|
||
|
||
n = len(values)
|
||
kurt = ((n * (n + 1)) / ((n - 1) * (n - 2) * (n - 3) * stddev ** 4)) * \
|
||
sum((x - mean) ** 4 for x in values) - \
|
||
(3 * (n - 1) ** 2) / ((n - 2) * (n - 3))
|
||
return kurt
|
||
|
||
@staticmethod
|
||
def calculate_correlation(x_values, y_values):
|
||
"""Calculate Pearson correlation coefficient"""
|
||
if not x_values or not y_values or len(x_values) != len(y_values):
|
||
return 0.0
|
||
if len(x_values) < 2:
|
||
return 0.0
|
||
|
||
mean_x = StatisticalAnalyzer.calculate_mean(x_values)
|
||
mean_y = StatisticalAnalyzer.calculate_mean(y_values)
|
||
stddev_x = StatisticalAnalyzer.calculate_stddev(x_values, mean_x)
|
||
stddev_y = StatisticalAnalyzer.calculate_stddev(y_values, mean_y)
|
||
|
||
if stddev_x == 0 or stddev_y == 0:
|
||
return 0.0
|
||
|
||
n = len(x_values)
|
||
covariance = sum((x_values[i] - mean_x) * (y_values[i] - mean_y) for i in range(n)) / n
|
||
correlation = covariance / (stddev_x * stddev_y)
|
||
|
||
return correlation
|
||
|
||
|
||
class AnalyticsGenerator:
|
||
"""Main analytics generator class"""
|
||
|
||
def calculate_analytics(self, template_name=None, start_date=None, end_date=None):
|
||
"""
|
||
Calculate comprehensive survey analytics.
|
||
|
||
Args:
|
||
template_name: Optional filter by survey template name
|
||
start_date: Optional start date (datetime.date or datetime)
|
||
end_date: Optional end date (datetime.date or datetime)
|
||
|
||
Returns:
|
||
dict: Complete analytics data
|
||
"""
|
||
# Parse date range
|
||
if start_date is None:
|
||
start_date = timezone.now() - timedelta(days=365)
|
||
else:
|
||
if not isinstance(start_date, datetime):
|
||
start_date = timezone.make_aware(datetime.combine(start_date, datetime.min.time()))
|
||
else:
|
||
start_date = timezone.make_aware(start_date) if timezone.is_naive(start_date) else start_date
|
||
|
||
if end_date is None:
|
||
end_date = timezone.now()
|
||
else:
|
||
if not isinstance(end_date, datetime):
|
||
end_date = timezone.make_aware(datetime.combine(end_date, datetime.max.time()))
|
||
else:
|
||
end_date = timezone.make_aware(end_date) if timezone.is_naive(end_date) else end_date
|
||
|
||
# Get survey templates
|
||
templates = SurveyTemplate.objects.filter(is_active=True)
|
||
if template_name:
|
||
templates = templates.filter(name__icontains=template_name)
|
||
templates = templates.order_by('name')
|
||
|
||
# Generate analytics data
|
||
analytics_data = {
|
||
'report_generated_at': timezone.now().isoformat(),
|
||
'date_range': {
|
||
'start': start_date.isoformat(),
|
||
'end': end_date.isoformat()
|
||
},
|
||
'templates': []
|
||
}
|
||
|
||
for template in templates:
|
||
template_data = self._analyze_template(template, start_date, end_date)
|
||
analytics_data['templates'].append(template_data)
|
||
|
||
# Add overall summary
|
||
analytics_data['summary'] = self._generate_summary(analytics_data['templates'])
|
||
|
||
return analytics_data
|
||
|
||
def _analyze_template(self, template, start_date, end_date):
|
||
"""Analyze a single survey template"""
|
||
# Get all instances for this template
|
||
instances = SurveyInstance.objects.filter(
|
||
survey_template=template,
|
||
created_at__gte=start_date,
|
||
created_at__lte=end_date
|
||
)
|
||
|
||
# Get completed instances
|
||
completed_instances = instances.filter(status='completed')
|
||
|
||
# Basic metrics
|
||
total_sent = instances.count()
|
||
total_completed = completed_instances.count()
|
||
completion_rate = (total_completed / total_sent * 100) if total_sent > 0 else 0
|
||
|
||
# Score metrics
|
||
avg_score = completed_instances.aggregate(avg=Avg('total_score'))['avg'] or 0
|
||
std_dev = completed_instances.aggregate(std=StdDev('total_score'))['std'] or 0
|
||
|
||
# Negative surveys
|
||
negative_count = completed_instances.filter(is_negative=True).count()
|
||
negative_rate = (negative_count / total_completed * 100) if total_completed > 0 else 0
|
||
|
||
# Score distribution
|
||
score_distribution = self._get_score_distribution(completed_instances)
|
||
|
||
# Status breakdown
|
||
status_breakdown = dict(
|
||
instances.values('status').annotate(count=Count('id')).values_list('status', 'count')
|
||
)
|
||
|
||
# Channel performance
|
||
channel_performance = self._get_channel_performance(instances)
|
||
|
||
# Monthly trends
|
||
monthly_trends = self._get_monthly_trends(completed_instances, start_date, end_date)
|
||
|
||
# Engagement metrics
|
||
engagement = self._get_engagement_metrics(completed_instances)
|
||
|
||
# Patient contact metrics
|
||
patient_contact = self._get_patient_contact_metrics(completed_instances)
|
||
|
||
# Comments
|
||
comments_data = self._get_comments_metrics(completed_instances)
|
||
|
||
# Question-level analytics
|
||
questions = self._analyze_questions(template, completed_instances)
|
||
|
||
# Generate question rankings and insights
|
||
template_data = {
|
||
'template_id': str(template.id),
|
||
'template_name': template.name,
|
||
'template_name_ar': template.name_ar,
|
||
'survey_type': template.survey_type,
|
||
'scoring_method': template.scoring_method,
|
||
'negative_threshold': float(template.negative_threshold),
|
||
'question_count': template.questions.count(),
|
||
'metrics': {
|
||
'total_sent': total_sent,
|
||
'total_completed': total_completed,
|
||
'completion_rate': round(completion_rate, 2),
|
||
'avg_score': round(float(avg_score), 2),
|
||
'std_deviation': round(float(std_dev), 2),
|
||
'negative_count': negative_count,
|
||
'negative_rate': round(negative_rate, 2),
|
||
},
|
||
'score_distribution': score_distribution,
|
||
'status_breakdown': status_breakdown,
|
||
'channel_performance': channel_performance,
|
||
'monthly_trends': monthly_trends,
|
||
'engagement_metrics': engagement,
|
||
'patient_contact_metrics': patient_contact,
|
||
'comments_metrics': comments_data,
|
||
'questions': questions
|
||
}
|
||
|
||
# Add rankings and insights
|
||
template_data['rankings'] = self._generate_question_rankings(template_data)
|
||
template_data['insights'] = self._generate_insights(template_data)
|
||
|
||
return template_data
|
||
|
||
def _get_score_distribution(self, instances):
|
||
"""Get score distribution"""
|
||
distribution = {
|
||
'excellent': instances.filter(total_score__gte=4.5).count(),
|
||
'good': instances.filter(total_score__gte=3.5, total_score__lt=4.5).count(),
|
||
'average': instances.filter(total_score__gte=2.5, total_score__lt=3.5).count(),
|
||
'poor': instances.filter(total_score__lt=2.5).count(),
|
||
}
|
||
|
||
total = instances.count()
|
||
for key in list(distribution.keys()):
|
||
distribution[f'{key}_percent'] = round(distribution[key] / total * 100, 2) if total > 0 else 0
|
||
|
||
return distribution
|
||
|
||
def _get_channel_performance(self, instances):
|
||
"""Get performance by delivery channel"""
|
||
channels = ['sms', 'whatsapp', 'email']
|
||
performance = {}
|
||
|
||
for channel in channels:
|
||
channel_instances = instances.filter(delivery_channel=channel)
|
||
total = channel_instances.count()
|
||
completed = channel_instances.filter(status='completed').count()
|
||
|
||
performance[channel] = {
|
||
'total_sent': total,
|
||
'completed': completed,
|
||
'completion_rate': round((completed / total * 100) if total > 0 else 0, 2),
|
||
'avg_score': round(float(
|
||
channel_instances.filter(status='completed').aggregate(
|
||
avg=Avg('total_score')
|
||
)['avg'] or 0
|
||
), 2)
|
||
}
|
||
|
||
return performance
|
||
|
||
def _get_monthly_trends(self, instances, start_date, end_date):
|
||
"""Get monthly trends"""
|
||
trends = []
|
||
current_date = start_date.replace(day=1)
|
||
|
||
while current_date <= end_date:
|
||
next_date = (current_date + timedelta(days=32)).replace(day=1)
|
||
|
||
month_instances = instances.filter(
|
||
completed_at__gte=current_date,
|
||
completed_at__lt=next_date
|
||
)
|
||
|
||
trends.append({
|
||
'month': current_date.strftime('%Y-%m'),
|
||
'month_name': current_date.strftime('%B %Y'),
|
||
'count': month_instances.count(),
|
||
'avg_score': round(float(
|
||
month_instances.aggregate(avg=Avg('total_score'))['avg'] or 0
|
||
), 2),
|
||
'negative_count': month_instances.filter(is_negative=True).count()
|
||
})
|
||
|
||
current_date = next_date
|
||
|
||
return trends
|
||
|
||
def _get_engagement_metrics(self, instances):
|
||
"""Get engagement metrics"""
|
||
# Time to complete
|
||
time_stats = instances.filter(
|
||
time_spent_seconds__isnull=False
|
||
).aggregate(
|
||
avg_time=Avg('time_spent_seconds'),
|
||
min_time=Avg('time_spent_seconds'),
|
||
max_time=Avg('time_spent_seconds')
|
||
)
|
||
|
||
# Open count
|
||
open_stats = instances.aggregate(
|
||
avg_opens=Avg('open_count'),
|
||
max_opens=Avg('open_count')
|
||
)
|
||
|
||
return {
|
||
'avg_completion_time_seconds': round(float(time_stats['avg_time'] or 0), 2),
|
||
'min_completion_time_seconds': round(float(time_stats['min_time'] or 0), 2),
|
||
'max_completion_time_seconds': round(float(time_stats['max_time'] or 0), 2),
|
||
'avg_opens': round(float(open_stats['avg_opens'] or 0), 2),
|
||
'max_opens': round(float(open_stats['max_opens'] or 0), 2)
|
||
}
|
||
|
||
def _get_patient_contact_metrics(self, instances):
|
||
"""Get patient contact metrics for negative surveys"""
|
||
negative_instances = instances.filter(is_negative=True)
|
||
|
||
contacted = negative_instances.filter(patient_contacted=True).count()
|
||
resolved = negative_instances.filter(issue_resolved=True).count()
|
||
|
||
return {
|
||
'total_negative': negative_instances.count(),
|
||
'contacted': contacted,
|
||
'contacted_rate': round((contacted / negative_instances.count() * 100) if negative_instances.count() > 0 else 0, 2),
|
||
'resolved': resolved,
|
||
'resolved_rate': round((resolved / negative_instances.count() * 100) if negative_instances.count() > 0 else 0, 2)
|
||
}
|
||
|
||
def _get_comments_metrics(self, instances):
|
||
"""Get comments metrics"""
|
||
with_comments = instances.exclude(comment='').count()
|
||
|
||
return {
|
||
'with_comments': with_comments,
|
||
'comment_rate': round((with_comments / instances.count() * 100) if instances.count() > 0 else 0, 2),
|
||
'avg_comment_length': round(float(
|
||
instances.exclude(comment='').annotate(
|
||
length=ExpressionWrapper(F('comment'), output_field=IntegerField())
|
||
).aggregate(avg=Avg('length'))['avg'] or 0
|
||
), 2)
|
||
}
|
||
|
||
def _analyze_questions(self, template, instances):
|
||
"""Analyze each question in the template"""
|
||
questions = template.questions.all().order_by('order')
|
||
question_analytics = []
|
||
|
||
for question in questions:
|
||
# Get responses for this question
|
||
responses = SurveyResponse.objects.filter(
|
||
survey_instance__in=instances,
|
||
question=question
|
||
)
|
||
|
||
response_count = responses.count()
|
||
|
||
if question.question_type in ['rating', 'likert', 'nps']:
|
||
# Numeric questions
|
||
numeric_responses = responses.filter(numeric_value__isnull=False)
|
||
|
||
avg_score = numeric_responses.aggregate(avg=Avg('numeric_value'))['avg'] or 0
|
||
std_dev = numeric_responses.aggregate(std=StdDev('numeric_value'))['std'] or 0
|
||
|
||
# Score distribution
|
||
score_dist = self._get_question_score_distribution(numeric_responses)
|
||
|
||
# Statistical analysis (skewness, kurtosis)
|
||
stats = self._calculate_question_statistics(numeric_responses)
|
||
|
||
# Correlation with overall survey score
|
||
correlation = self._calculate_question_correlation(question, instances)
|
||
|
||
# Performance by channel
|
||
channel_performance = self._get_question_channel_performance(question, instances)
|
||
|
||
# Monthly trends
|
||
monthly_trends = self._get_question_monthly_trends(
|
||
question, instances, self._get_date_range_from_instances(instances)
|
||
)
|
||
|
||
question_data = {
|
||
'question_id': str(question.id),
|
||
'question_text': question.text,
|
||
'question_text_ar': question.text_ar,
|
||
'question_type': question.question_type,
|
||
'order': question.order,
|
||
'is_required': question.is_required,
|
||
'response_count': response_count,
|
||
'response_rate': round((response_count / instances.count() * 100) if instances.count() > 0 else 0, 2),
|
||
'avg_score': round(float(avg_score), 2),
|
||
'std_deviation': round(float(std_dev), 2),
|
||
'skewness': round(stats['skewness'], 3),
|
||
'kurtosis': round(stats['kurtosis'], 3),
|
||
'correlation_with_overall': round(correlation, 3),
|
||
'score_distribution': score_dist,
|
||
'channel_performance': channel_performance,
|
||
'monthly_trends': monthly_trends,
|
||
'has_text_responses': False
|
||
}
|
||
|
||
elif question.question_type in ['text', 'textarea']:
|
||
# Text questions
|
||
text_responses = responses.exclude(text_value='')
|
||
|
||
question_data = {
|
||
'question_id': str(question.id),
|
||
'question_text': question.text,
|
||
'question_text_ar': question.text_ar,
|
||
'question_type': question.question_type,
|
||
'order': question.order,
|
||
'is_required': question.is_required,
|
||
'response_count': response_count,
|
||
'response_rate': round((response_count / instances.count() * 100) if instances.count() > 0 else 0, 2),
|
||
'text_response_count': text_responses.count(),
|
||
'avg_text_length': round(float(
|
||
text_responses.annotate(
|
||
length=ExpressionWrapper(F('text_value'), output_field=IntegerField())
|
||
).aggregate(avg=Avg('length'))['avg'] or 0
|
||
), 2),
|
||
'has_numeric_responses': False
|
||
}
|
||
|
||
elif question.question_type == 'multiple_choice':
|
||
# Multiple choice
|
||
choice_dist = self._get_choice_distribution(responses)
|
||
|
||
question_data = {
|
||
'question_id': str(question.id),
|
||
'question_text': question.text,
|
||
'question_text_ar': question.text_ar,
|
||
'question_type': question.question_type,
|
||
'order': question.order,
|
||
'is_required': question.is_required,
|
||
'response_count': response_count,
|
||
'response_rate': round((response_count / instances.count() * 100) if instances.count() > 0 else 0, 2),
|
||
'choice_distribution': choice_dist,
|
||
'has_numeric_responses': False
|
||
}
|
||
|
||
else:
|
||
question_data = {
|
||
'question_id': str(question.id),
|
||
'question_text': question.text,
|
||
'question_text_ar': question.text_ar,
|
||
'question_type': question.question_type,
|
||
'order': question.order,
|
||
'is_required': question.is_required,
|
||
'response_count': response_count,
|
||
'response_rate': round((response_count / instances.count() * 100) if instances.count() > 0 else 0, 2),
|
||
}
|
||
|
||
question_analytics.append(question_data)
|
||
|
||
return question_analytics
|
||
|
||
def _get_question_score_distribution(self, responses):
|
||
"""Get score distribution for a question"""
|
||
distribution = {}
|
||
scores = [1, 2, 3, 4, 5]
|
||
|
||
for score in scores:
|
||
count = responses.filter(numeric_value=score).count()
|
||
distribution[f'score_{score}'] = count
|
||
|
||
total = responses.count()
|
||
for score in scores:
|
||
distribution[f'score_{score}_percent'] = round(
|
||
distribution[f'score_{score}'] / total * 100, 2
|
||
) if total > 0 else 0
|
||
|
||
return distribution
|
||
|
||
def _get_question_monthly_trends(self, question, instances, date_range):
|
||
"""Get monthly trends for a question"""
|
||
start_date, end_date = date_range
|
||
trends = []
|
||
current_date = start_date.replace(day=1)
|
||
|
||
while current_date <= end_date:
|
||
next_date = (current_date + timedelta(days=32)).replace(day=1)
|
||
|
||
month_responses = SurveyResponse.objects.filter(
|
||
survey_instance__in=instances,
|
||
question=question,
|
||
created_at__gte=current_date,
|
||
created_at__lt=next_date
|
||
).filter(numeric_value__isnull=False)
|
||
|
||
trends.append({
|
||
'month': current_date.strftime('%Y-%m'),
|
||
'month_name': current_date.strftime('%B %Y'),
|
||
'count': month_responses.count(),
|
||
'avg_score': round(float(
|
||
month_responses.aggregate(avg=Avg('numeric_value'))['avg'] or 0
|
||
), 2)
|
||
})
|
||
|
||
current_date = next_date
|
||
|
||
return trends
|
||
|
||
def _get_choice_distribution(self, responses):
|
||
"""Get distribution for multiple choice questions"""
|
||
distribution = dict(
|
||
responses.values('choice_value').annotate(
|
||
count=Count('id')
|
||
).values_list('choice_value', 'count')
|
||
)
|
||
|
||
total = responses.count()
|
||
if total > 0:
|
||
for choice in list(distribution.keys()):
|
||
distribution[f'{choice}_percent'] = round(
|
||
distribution[choice] / total * 100, 2
|
||
)
|
||
|
||
return distribution
|
||
|
||
def _get_date_range_from_instances(self, instances):
|
||
"""Get date range from instances"""
|
||
first = instances.order_by('completed_at').first()
|
||
last = instances.order_by('-completed_at').first()
|
||
|
||
if first and last:
|
||
return first.completed_at, last.completed_at
|
||
return timezone.now() - timedelta(days=365), timezone.now()
|
||
|
||
def _calculate_question_statistics(self, responses):
|
||
"""Calculate statistical metrics for a question"""
|
||
# Extract numeric values
|
||
values = list(responses.values_list('numeric_value', flat=True))
|
||
values = [float(v) for v in values if v is not None]
|
||
|
||
if not values:
|
||
return {'skewness': 0.0, 'kurtosis': 0.0}
|
||
|
||
# Calculate mean and std dev
|
||
mean = StatisticalAnalyzer.calculate_mean(values)
|
||
stddev = StatisticalAnalyzer.calculate_stddev(values, mean)
|
||
|
||
# Calculate skewness and kurtosis
|
||
skewness = StatisticalAnalyzer.calculate_skewness(values, mean, stddev)
|
||
kurtosis = StatisticalAnalyzer.calculate_kurtosis(values, mean, stddev)
|
||
|
||
return {
|
||
'skewness': skewness,
|
||
'kurtosis': kurtosis,
|
||
'mean': mean,
|
||
'stddev': stddev
|
||
}
|
||
|
||
def _calculate_question_correlation(self, question, instances):
|
||
"""Calculate correlation between question score and overall survey score"""
|
||
# Get responses for this question along with survey scores
|
||
question_responses = SurveyResponse.objects.filter(
|
||
survey_instance__in=instances,
|
||
question=question,
|
||
numeric_value__isnull=False
|
||
).select_related('survey_instance')
|
||
|
||
if question_responses.count() < 2:
|
||
return 0.0
|
||
|
||
# Extract paired values
|
||
question_scores = []
|
||
survey_scores = []
|
||
|
||
for response in question_responses:
|
||
question_score = float(response.numeric_value)
|
||
survey_score = float(response.survey_instance.total_score) if response.survey_instance.total_score else 0.0
|
||
question_scores.append(question_score)
|
||
survey_scores.append(survey_score)
|
||
|
||
# Calculate correlation
|
||
return StatisticalAnalyzer.calculate_correlation(question_scores, survey_scores)
|
||
|
||
def _get_question_channel_performance(self, question, instances):
|
||
"""Get question performance by delivery channel"""
|
||
channels = ['sms', 'whatsapp', 'email']
|
||
performance = {}
|
||
|
||
for channel in channels:
|
||
channel_instances = instances.filter(delivery_channel=channel)
|
||
channel_responses = SurveyResponse.objects.filter(
|
||
survey_instance__in=channel_instances,
|
||
question=question,
|
||
numeric_value__isnull=False
|
||
)
|
||
|
||
count = channel_responses.count()
|
||
if count > 0:
|
||
avg = channel_responses.aggregate(avg=Avg('numeric_value'))['avg'] or 0
|
||
performance[channel] = {
|
||
'response_count': count,
|
||
'avg_score': round(float(avg), 2)
|
||
}
|
||
else:
|
||
performance[channel] = {
|
||
'response_count': 0,
|
||
'avg_score': 0.0
|
||
}
|
||
|
||
return performance
|
||
|
||
def _generate_question_rankings(self, template_data):
|
||
"""Generate question rankings based on various metrics"""
|
||
numeric_questions = [
|
||
q for q in template_data['questions']
|
||
if q.get('has_numeric_responses', True) or 'avg_score' in q
|
||
]
|
||
|
||
# Sort by average score
|
||
sorted_by_score = sorted(
|
||
numeric_questions,
|
||
key=lambda x: x.get('avg_score', 0),
|
||
reverse=True
|
||
)
|
||
|
||
# Sort by correlation
|
||
sorted_by_correlation = sorted(
|
||
numeric_questions,
|
||
key=lambda x: x.get('correlation_with_overall', 0),
|
||
reverse=True
|
||
)
|
||
|
||
# Sort by response rate (most skipped)
|
||
sorted_by_response_rate = sorted(
|
||
template_data['questions'],
|
||
key=lambda x: x.get('response_rate', 0)
|
||
)
|
||
|
||
return {
|
||
'top_5_by_score': [
|
||
{
|
||
'question': q['question_text'],
|
||
'order': q['order'],
|
||
'avg_score': q['avg_score']
|
||
}
|
||
for q in sorted_by_score[:5]
|
||
],
|
||
'bottom_5_by_score': [
|
||
{
|
||
'question': q['question_text'],
|
||
'order': q['order'],
|
||
'avg_score': q['avg_score']
|
||
}
|
||
for q in sorted_by_score[-5:] if sorted_by_score
|
||
],
|
||
'top_5_by_correlation': [
|
||
{
|
||
'question': q['question_text'],
|
||
'order': q['order'],
|
||
'correlation': q.get('correlation_with_overall', 0)
|
||
}
|
||
for q in sorted_by_correlation[:5]
|
||
],
|
||
'most_skipped_5': [
|
||
{
|
||
'question': q['question_text'],
|
||
'order': q['order'],
|
||
'response_rate': q['response_rate']
|
||
}
|
||
for q in sorted_by_response_rate[:5]
|
||
]
|
||
}
|
||
|
||
def _generate_insights(self, template_data):
|
||
"""Generate actionable insights based on template analytics"""
|
||
insights = []
|
||
|
||
metrics = template_data['metrics']
|
||
score_dist = template_data['score_distribution']
|
||
channel_perf = template_data['channel_performance']
|
||
rankings = template_data['rankings']
|
||
|
||
# Completion rate insights
|
||
if metrics['completion_rate'] < 50:
|
||
insights.append({
|
||
'category': 'Engagement',
|
||
'severity': 'high',
|
||
'message': f'Low completion rate ({metrics["completion_rate"]}%). Consider improving survey timing and delivery channels.'
|
||
})
|
||
elif metrics['completion_rate'] > 80:
|
||
insights.append({
|
||
'category': 'Engagement',
|
||
'severity': 'positive',
|
||
'message': f'Excellent completion rate ({metrics["completion_rate"]}%) showing strong patient engagement.'
|
||
})
|
||
|
||
# Average score insights
|
||
if metrics['avg_score'] < 3.0:
|
||
insights.append({
|
||
'category': 'Performance',
|
||
'severity': 'high',
|
||
'message': f'Below average performance ({metrics["avg_score"]}/5.0). Review worst performing questions for improvement opportunities.'
|
||
})
|
||
elif metrics['avg_score'] >= 4.5:
|
||
insights.append({
|
||
'category': 'Performance',
|
||
'severity': 'positive',
|
||
'message': f'Outstanding performance ({metrics["avg_score"]}/5.0). Maintain current service levels.'
|
||
})
|
||
|
||
# Negative rate insights
|
||
if metrics['negative_rate'] > 20:
|
||
insights.append({
|
||
'category': 'Quality',
|
||
'severity': 'high',
|
||
'message': f'High negative survey rate ({metrics["negative_rate"]}%). Immediate action required to address patient concerns.'
|
||
})
|
||
elif metrics['negative_rate'] < 5:
|
||
insights.append({
|
||
'category': 'Quality',
|
||
'severity': 'positive',
|
||
'message': f'Low negative survey rate ({metrics["negative_rate"]}%). Excellent patient satisfaction.'
|
||
})
|
||
|
||
# Score distribution insights
|
||
poor_percent = score_dist['poor_percent']
|
||
if poor_percent > 15:
|
||
insights.append({
|
||
'category': 'Distribution',
|
||
'severity': 'medium',
|
||
'message': f'High percentage of poor scores ({poor_percent}%). Investigate root causes of dissatisfaction.'
|
||
})
|
||
|
||
excellent_percent = score_dist['excellent_percent']
|
||
if excellent_percent > 60:
|
||
insights.append({
|
||
'category': 'Distribution',
|
||
'severity': 'positive',
|
||
'message': f'Majority of responses are excellent ({excellent_percent}%). Outstanding service delivery.'
|
||
})
|
||
|
||
# Channel performance insights
|
||
best_channel = max(channel_perf.items(), key=lambda x: x[1]['completion_rate'])[0]
|
||
worst_channel = min(channel_perf.items(), key=lambda x: x[1]['completion_rate'])[0]
|
||
|
||
if channel_perf[best_channel]['completion_rate'] - channel_perf[worst_channel]['completion_rate'] > 30:
|
||
insights.append({
|
||
'category': 'Channels',
|
||
'severity': 'medium',
|
||
'message': f'Significant channel performance gap. {best_channel.capitalize()} performs much better ({channel_perf[best_channel]["completion_rate"]}%) than {worst_channel.capitalize()} ({channel_perf[worst_channel]["completion_rate"]}%).'
|
||
})
|
||
|
||
# Question-specific insights
|
||
if rankings['bottom_5_by_score']:
|
||
worst_question = rankings['bottom_5_by_score'][0]
|
||
if worst_question['avg_score'] < 3.0:
|
||
insights.append({
|
||
'category': 'Questions',
|
||
'severity': 'medium',
|
||
'message': f'Question {worst_question["order"]}: "{worst_question["question"]}" has lowest average score ({worst_question["avg_score"]}/5.0). Consider reviewing this service area.'
|
||
})
|
||
|
||
if rankings['most_skipped_5']:
|
||
most_skipped = rankings['most_skipped_5'][0]
|
||
if most_skipped['response_rate'] < 70:
|
||
insights.append({
|
||
'category': 'Questions',
|
||
'severity': 'low',
|
||
'message': f'Question {most_skipped["order"]}: "{most_skipped["question"]}" has a low response rate ({most_skipped["response_rate"]}%). Consider making it optional or improving clarity.'
|
||
})
|
||
|
||
# Patient contact metrics
|
||
patient_contact = template_data.get('patient_contact_metrics', {})
|
||
if patient_contact.get('contacted_rate', 0) < 50 and patient_contact.get('total_negative', 0) > 0:
|
||
insights.append({
|
||
'category': 'Follow-up',
|
||
'severity': 'medium',
|
||
'message': f'Only {patient_contact["contacted_rate"]}% of negative survey patients have been contacted. Improve follow-up processes.'
|
||
})
|
||
|
||
return insights
|
||
|
||
def _generate_summary(self, templates_data):
|
||
"""Generate overall summary across all templates"""
|
||
total_sent = sum(t['metrics']['total_sent'] for t in templates_data)
|
||
total_completed = sum(t['metrics']['total_completed'] for t in templates_data)
|
||
total_negative = sum(t['metrics']['negative_count'] for t in templates_data)
|
||
|
||
# Calculate weighted average score
|
||
weighted_score_sum = sum(
|
||
t['metrics']['avg_score'] * t['metrics']['total_completed']
|
||
for t in templates_data
|
||
)
|
||
weighted_avg_score = weighted_score_sum / total_completed if total_completed > 0 else 0
|
||
|
||
# Find best and worst performing templates
|
||
sorted_by_score = sorted(templates_data, key=lambda x: x['metrics']['avg_score'], reverse=True)
|
||
|
||
return {
|
||
'total_templates': len(templates_data),
|
||
'total_surveys_sent': total_sent,
|
||
'total_surveys_completed': total_completed,
|
||
'overall_completion_rate': round((total_completed / total_sent * 100) if total_sent > 0 else 0, 2),
|
||
'overall_avg_score': round(weighted_avg_score, 2),
|
||
'total_negative_surveys': total_negative,
|
||
'overall_negative_rate': round((total_negative / total_completed * 100) if total_completed > 0 else 0, 2),
|
||
'best_performing_template': {
|
||
'name': sorted_by_score[0]['template_name'],
|
||
'avg_score': sorted_by_score[0]['metrics']['avg_score']
|
||
},
|
||
'worst_performing_template': {
|
||
'name': sorted_by_score[-1]['template_name'],
|
||
'avg_score': sorted_by_score[-1]['metrics']['avg_score']
|
||
}
|
||
}
|
||
|
||
|
||
class ReportGenerator:
|
||
"""Report generation helper class"""
|
||
|
||
def generate_markdown_to_file(self, data, output_dir):
|
||
"""Generate Markdown report to file"""
|
||
output_path = output_dir / 'survey_analytics_report.md' if hasattr(output_dir, 'joinpath') else \
|
||
f'{output_dir}/survey_analytics_report.md'
|
||
|
||
markdown_content = self.generate_markdown(data)
|
||
|
||
with open(output_path, 'w') as f:
|
||
f.write(markdown_content)
|
||
|
||
return output_path
|
||
|
||
def generate_markdown(self, data):
|
||
"""Generate Markdown report content"""
|
||
lines = []
|
||
lines.append('# Survey Analytics Report\n\n')
|
||
lines.append(f'**Generated:** {data["report_generated_at"]}\n\n')
|
||
lines.append(f'**Date Range:** {data["date_range"]["start"][:10]} to {data["date_range"]["end"][:10]}\n\n')
|
||
|
||
# Summary
|
||
lines.append('## Executive Summary\n\n')
|
||
summary = data['summary']
|
||
lines.append(f'- **Total Survey Templates:** {summary["total_templates"]}\n')
|
||
lines.append(f'- **Total Surveys Sent:** {summary["total_surveys_sent"]:,}\n')
|
||
lines.append(f'- **Total Surveys Completed:** {summary["total_surveys_completed"]:,}\n')
|
||
lines.append(f'- **Overall Completion Rate:** {summary["overall_completion_rate"]}%\n')
|
||
lines.append(f'- **Overall Average Score:** {summary["overall_avg_score"]}/5.0\n')
|
||
lines.append(f'- **Total Negative Surveys:** {summary["total_negative_surveys"]:,} ({summary["overall_negative_rate"]}%)\n\n')
|
||
|
||
lines.append(f'### Best Performing Template\n')
|
||
lines.append(f'**{summary["best_performing_template"]["name"]}**\n')
|
||
lines.append(f'Average Score: {summary["best_performing_template"]["avg_score"]}/5.0\n\n')
|
||
|
||
lines.append(f'### Worst Performing Template\n')
|
||
lines.append(f'**{summary["worst_performing_template"]["name"]}**\n')
|
||
lines.append(f'Average Score: {summary["worst_performing_template"]["avg_score"]}/5.0\n\n')
|
||
|
||
# Template details
|
||
for template_data in data['templates']:
|
||
lines.append(f'## {template_data["template_name"]}\n\n')
|
||
|
||
# Template metrics
|
||
metrics = template_data['metrics']
|
||
lines.append('### Overview\n\n')
|
||
lines.append(f'- **Survey Type:** {template_data["survey_type"]}\n')
|
||
lines.append(f'- **Scoring Method:** {template_data["scoring_method"]}\n')
|
||
lines.append(f'- **Questions:** {template_data["question_count"]}\n')
|
||
lines.append(f'- **Total Sent:** {metrics["total_sent"]:,}\n')
|
||
lines.append(f'- **Completed:** {metrics["total_completed"]:,}\n')
|
||
lines.append(f'- **Completion Rate:** {metrics["completion_rate"]}%\n')
|
||
lines.append(f'- **Average Score:** {metrics["avg_score"]}/5.0 (±{metrics["std_deviation"]})\n')
|
||
lines.append(f'- **Negative Surveys:** {metrics["negative_count"]:,} ({metrics["negative_rate"]}%)\n\n')
|
||
|
||
# Score distribution
|
||
lines.append('### Score Distribution\n\n')
|
||
lines.append('| Category | Count | Percentage |\n')
|
||
lines.append('|----------|-------|------------|\n')
|
||
for cat in ['excellent', 'good', 'average', 'poor']:
|
||
count = template_data['score_distribution'][cat]
|
||
percent = template_data['score_distribution'][f'{cat}_percent']
|
||
lines.append(f'| {cat.capitalize()} | {count} | {percent}% |\n')
|
||
lines.append('\n')
|
||
|
||
# Channel performance
|
||
lines.append('### Channel Performance\n\n')
|
||
lines.append('| Channel | Sent | Completed | Rate | Avg Score |\n')
|
||
lines.append('|---------|-------|-----------|-------|----------|\n')
|
||
for channel, perf in template_data['channel_performance'].items():
|
||
lines.append(f'| {channel.capitalize()} | {perf["total_sent"]} | {perf["completed"]} | {perf["completion_rate"]}% | {perf["avg_score"]} |\n')
|
||
lines.append('\n')
|
||
|
||
# Questions
|
||
lines.append('### Question Analysis\n\n')
|
||
for question in template_data['questions']:
|
||
lines.append(f'#### Q{question["order"]}: {question["question_text"]}\n\n')
|
||
lines.append(f'- **Type:** {question["question_type"]}\n')
|
||
lines.append(f'- **Required:** {"Yes" if question["is_required"] else "No"}\n')
|
||
lines.append(f'- **Response Rate:** {question["response_rate"]}% ({question["response_count"]} responses)\n')
|
||
|
||
if 'avg_score' in question:
|
||
lines.append(f'- **Average Score:** {question["avg_score"]}/5.0 (±{question["std_deviation"]})\n')
|
||
|
||
if 'score_distribution' in question:
|
||
lines.append('\n**Score Distribution:**\n\n')
|
||
lines.append('| Score | Count | % |\n')
|
||
lines.append('|-------|-------|----|\n')
|
||
for i in range(1, 6):
|
||
count = question['score_distribution'][f'score_{i}']
|
||
percent = question['score_distribution'][f'score_{i}_percent']
|
||
lines.append(f'| {i} | {count} | {percent}% |\n')
|
||
|
||
lines.append('\n')
|
||
|
||
lines.append('---\n\n')
|
||
|
||
return ''.join(lines)
|
||
|
||
def generate_html_to_file(self, data, output_dir):
|
||
"""Generate HTML report to file"""
|
||
output_path = output_dir / 'survey_analytics_report.html' if hasattr(output_dir, 'joinpath') else \
|
||
f'{output_dir}/survey_analytics_report.html'
|
||
|
||
html_content = self.generate_html(data)
|
||
|
||
with open(output_path, 'w') as f:
|
||
f.write(html_content)
|
||
|
||
return output_path
|
||
|
||
def generate_html(self, data):
|
||
"""Generate HTML content for report"""
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Survey Analytics Report</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||
<style>
|
||
body {{
|
||
font-family: Arial, sans-serif;
|
||
margin: 20px;
|
||
background-color: #f5f5f5;
|
||
}}
|
||
.container {{
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
background-color: white;
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}}
|
||
h1 {{
|
||
color: #333;
|
||
border-bottom: 2px solid #007bff;
|
||
padding-bottom: 10px;
|
||
}}
|
||
h2 {{
|
||
color: #555;
|
||
margin-top: 30px;
|
||
}}
|
||
.summary {{
|
||
background-color: #f8f9fa;
|
||
padding: 15px;
|
||
border-radius: 5px;
|
||
margin: 20px 0;
|
||
}}
|
||
.summary-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 15px;
|
||
}}
|
||
.summary-card {{
|
||
background-color: white;
|
||
padding: 15px;
|
||
border-radius: 5px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
}}
|
||
.summary-card h3 {{
|
||
margin: 0 0 10px 0;
|
||
color: #666;
|
||
font-size: 14px;
|
||
}}
|
||
.summary-card .value {{
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
color: #007bff;
|
||
}}
|
||
.chart {{
|
||
margin: 30px 0;
|
||
padding: 20px;
|
||
background-color: #f8f9fa;
|
||
border-radius: 5px;
|
||
}}
|
||
table {{
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 20px 0;
|
||
}}
|
||
th, td {{
|
||
padding: 12px;
|
||
text-align: left;
|
||
border-bottom: 1px solid #ddd;
|
||
}}
|
||
th {{
|
||
background-color: #007bff;
|
||
color: white;
|
||
}}
|
||
tr:hover {{
|
||
background-color: #f5f5f5;
|
||
}}
|
||
.negative {{
|
||
color: #dc3545;
|
||
}}
|
||
.positive {{
|
||
color: #28a745;
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>📊 Survey Analytics Report</h1>
|
||
<p><strong>Generated:</strong> {data['report_generated_at'][:19]}</p>
|
||
<p><strong>Date Range:</strong> {data['date_range']['start'][:10]} to {data['date_range']['end'][:10]}</p>
|
||
|
||
<div class="summary">
|
||
<h2>Executive Summary</h2>
|
||
<div class="summary-grid">
|
||
<div class="summary-card">
|
||
<h3>Total Templates</h3>
|
||
<div class="value">{data['summary']['total_templates']}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Total Surveys Sent</h3>
|
||
<div class="value">{data['summary']['total_surveys_sent']:,}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Total Completed</h3>
|
||
<div class="value">{data['summary']['total_surveys_completed']:,}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Completion Rate</h3>
|
||
<div class="value">{data['summary']['overall_completion_rate']}%</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Average Score</h3>
|
||
<div class="value">{data['summary']['overall_avg_score']}/5.0</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Negative Surveys</h3>
|
||
<div class="value negative">{data['summary']['total_negative_surveys']:,}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{self._generate_template_sections_html(data)}
|
||
</div>
|
||
|
||
<script>
|
||
// Initialize charts
|
||
{self._generate_charts_js(data)}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
def _generate_template_sections_html(self, data):
|
||
"""Generate HTML sections for each template"""
|
||
sections = []
|
||
|
||
for i, template_data in enumerate(data['templates']):
|
||
section_id = f"template_{i}"
|
||
|
||
# Score distribution chart
|
||
dist_chart_id = f"scoreDist_{i}"
|
||
monthly_chart_id = f"monthly_{i}"
|
||
|
||
html = f"""
|
||
<div id="{section_id}" class="template-section">
|
||
<h2>{template_data['template_name']}</h2>
|
||
|
||
<div class="summary">
|
||
<div class="summary-grid">
|
||
<div class="summary-card">
|
||
<h3>Total Sent</h3>
|
||
<div class="value">{template_data['metrics']['total_sent']:,}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Completed</h3>
|
||
<div class="value">{template_data['metrics']['total_completed']:,}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Completion Rate</h3>
|
||
<div class="value">{template_data['metrics']['completion_rate']}%</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Average Score</h3>
|
||
<div class="value positive">{template_data['metrics']['avg_score']}/5.0</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Negative Surveys</h3>
|
||
<div class="value negative">{template_data['metrics']['negative_count']:,}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="{dist_chart_id}" class="chart" style="height: 400px;"></div>
|
||
|
||
<div id="{monthly_chart_id}" class="chart" style="height: 400px;"></div>
|
||
|
||
<h3>Question Analysis</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Question</th>
|
||
<th>Type</th>
|
||
<th>Responses</th>
|
||
<th>Response Rate</th>
|
||
<th>Avg Score</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
"""
|
||
|
||
for question in template_data['questions']:
|
||
score = question.get('avg_score', 'N/A')
|
||
score_class = 'positive' if isinstance(score, (int, float)) and score >= 4.0 else ''
|
||
if isinstance(score, (int, float)):
|
||
score = f"{score}/5.0"
|
||
|
||
html += f"""
|
||
<tr>
|
||
<td>Q{question['order']}: {question['question_text']}</td>
|
||
<td>{question['question_type']}</td>
|
||
<td>{question['response_count']}</td>
|
||
<td>{question['response_rate']}%</td>
|
||
<td class="{score_class}">{score}</td>
|
||
</tr>
|
||
"""
|
||
|
||
html += """
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
"""
|
||
|
||
sections.append(html)
|
||
|
||
return '\n'.join(sections)
|
||
|
||
def _generate_charts_js(self, data):
|
||
"""Generate JavaScript for charts"""
|
||
js = []
|
||
|
||
for i, template_data in enumerate(data['templates']):
|
||
# Score distribution chart
|
||
dist_chart_id = f"scoreDist_{i}"
|
||
dist_data = template_data['score_distribution']
|
||
|
||
js.append(f"""
|
||
// Score Distribution Chart for {template_data['template_name']}
|
||
const {dist_chart_id} = new ApexCharts(document.querySelector("#{dist_chart_id}"), {{
|
||
series: [{dist_data['excellent']}, {dist_data['good']}, {dist_data['average']}, {dist_data['poor']}],
|
||
chart: {{
|
||
type: 'donut',
|
||
}},
|
||
labels: ['Excellent', 'Good', 'Average', 'Poor'],
|
||
title: {{
|
||
text: 'Score Distribution'
|
||
}},
|
||
colors: ['#28a745', '#17a2b8', '#ffc107', '#dc3545']
|
||
}});
|
||
{dist_chart_id}.render();
|
||
""")
|
||
|
||
# Monthly trends chart
|
||
monthly_chart_id = f"monthly_{i}"
|
||
monthly_data = template_data['monthly_trends']
|
||
months = [t['month_name'] for t in monthly_data]
|
||
scores = [t['avg_score'] for t in monthly_data]
|
||
counts = [t['count'] for t in monthly_data]
|
||
|
||
js.append(f"""
|
||
// Monthly Trends Chart for {template_data['template_name']}
|
||
const {monthly_chart_id} = new ApexCharts(document.querySelector("#{monthly_chart_id}"), {{
|
||
series: [{{
|
||
name: 'Average Score',
|
||
data: {scores}
|
||
}}, {{
|
||
name: 'Survey Count',
|
||
data: {counts}
|
||
}}],
|
||
chart: {{
|
||
type: 'line',
|
||
height: 400
|
||
}},
|
||
xaxis: {{
|
||
categories: {months}
|
||
}},
|
||
title: {{
|
||
text: 'Monthly Trends'
|
||
}},
|
||
yaxis: [{{
|
||
title: {{
|
||
text: 'Score'
|
||
}}
|
||
}}, {{
|
||
opposite: true,
|
||
title: {{
|
||
text: 'Count'
|
||
}}
|
||
}}]
|
||
}});
|
||
{monthly_chart_id}.render();
|
||
""")
|
||
|
||
return '\n'.join(js)
|
||
|
||
|
||
# Convenience functions for direct import
|
||
def calculate_survey_analytics(template_name=None, start_date=None, end_date=None):
|
||
"""
|
||
Calculate comprehensive survey analytics.
|
||
|
||
This function can be imported and used by API.
|
||
|
||
Args:
|
||
template_name: Optional filter by survey template name
|
||
start_date: Optional start date (datetime.date or datetime)
|
||
end_date: Optional end date (datetime.date or datetime)
|
||
|
||
Returns:
|
||
dict: Complete analytics data
|
||
"""
|
||
analyzer = AnalyticsGenerator()
|
||
return analyzer.calculate_analytics(template_name, start_date, end_date)
|
||
|
||
|
||
def generate_markdown_report(analytics_data):
|
||
"""
|
||
Generate Markdown report from analytics data.
|
||
|
||
Args:
|
||
analytics_data: Dictionary of analytics data
|
||
|
||
Returns:
|
||
str: Markdown formatted report
|
||
"""
|
||
report_gen = ReportGenerator()
|
||
return report_gen.generate_markdown(analytics_data)
|
||
|
||
|
||
def generate_html_report(analytics_data):
|
||
"""
|
||
Generate HTML report from analytics data.
|
||
|
||
Args:
|
||
analytics_data: Dictionary of analytics data
|
||
|
||
Returns:
|
||
str: HTML formatted report
|
||
"""
|
||
report_gen = ReportGenerator()
|
||
return report_gen.generate_html(analytics_data)
|
||
|
||
# ============================================================================
|
||
# ENHANCED MULTI-REPORT GENERATOR - Separate reports per survey type
|
||
# ============================================================================
|
||
|
||
class MultiReportGenerator:
|
||
"""
|
||
Generates separate HTML reports for each survey template with enhanced
|
||
question-level analysis. Creates a master index page linking all reports.
|
||
"""
|
||
|
||
def __init__(self, output_dir):
|
||
self.output_dir = output_dir
|
||
self.reports_generated = []
|
||
|
||
def generate_reports(self, analytics_data):
|
||
"""
|
||
Generate separate HTML reports for each survey template.
|
||
|
||
Args:
|
||
analytics_data: Output from calculate_survey_analytics()
|
||
|
||
Returns:
|
||
dict: Paths to generated reports and master index
|
||
"""
|
||
import os
|
||
from datetime import datetime
|
||
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
reports_dir = os.path.join(self.output_dir, f"reports_{timestamp}")
|
||
os.makedirs(reports_dir, exist_ok=True)
|
||
|
||
generated_files = []
|
||
|
||
# Generate individual reports for each template
|
||
for template_data in analytics_data['templates']:
|
||
report_filename = self._sanitize_filename(template_data['template_name'])
|
||
report_path = os.path.join(reports_dir, f"{report_filename}.html")
|
||
|
||
html_content = self._generate_single_template_report(
|
||
template_data,
|
||
analytics_data['date_range'],
|
||
analytics_data['report_generated_at']
|
||
)
|
||
|
||
with open(report_path, 'w', encoding='utf-8') as f:
|
||
f.write(html_content)
|
||
|
||
generated_files.append({
|
||
'template_name': template_data['template_name'],
|
||
'template_id': template_data['template_id'],
|
||
'filename': f"{report_filename}.html",
|
||
'path': report_path,
|
||
'size': os.path.getsize(report_path)
|
||
})
|
||
|
||
# Generate master index file
|
||
index_path = os.path.join(reports_dir, "index.html")
|
||
index_content = self._generate_master_index(
|
||
generated_files,
|
||
analytics_data['summary'],
|
||
analytics_data['date_range'],
|
||
analytics_data['report_generated_at']
|
||
)
|
||
|
||
with open(index_path, 'w', encoding='utf-8') as f:
|
||
f.write(index_content)
|
||
|
||
# Generate summary JSON for programmatic access
|
||
summary_path = os.path.join(reports_dir, "summary.json")
|
||
import json
|
||
with open(summary_path, 'w', encoding='utf-8') as f:
|
||
json.dump({
|
||
'generated_at': analytics_data['report_generated_at'],
|
||
'date_range': analytics_data['date_range'],
|
||
'summary': analytics_data['summary'],
|
||
'reports': generated_files
|
||
}, f, indent=2, default=str)
|
||
|
||
return {
|
||
'reports_dir': reports_dir,
|
||
'index_path': index_path,
|
||
'summary_path': summary_path,
|
||
'individual_reports': generated_files
|
||
}
|
||
|
||
def _sanitize_filename(self, name):
|
||
"""Convert template name to safe filename"""
|
||
import re
|
||
# Remove special characters and replace spaces with underscores
|
||
safe = re.sub(r'[^\w\s-]', '', name)
|
||
safe = re.sub(r'[-\s]+', '_', safe)
|
||
return safe.lower()[:50]
|
||
|
||
def _generate_single_template_report(self, template_data, date_range, generated_at):
|
||
"""Generate detailed HTML report for a single survey template"""
|
||
|
||
# Build question analysis sections
|
||
question_sections = self._build_question_sections(template_data)
|
||
|
||
# Build charts
|
||
charts_js = self._build_question_charts_js(template_data)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{template_data['template_name']} - Survey Analytics Report</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||
<style>
|
||
:root {{
|
||
--primary-color: #2563eb;
|
||
--success-color: #10b981;
|
||
--warning-color: #f59e0b;
|
||
--danger-color: #ef4444;
|
||
--info-color: #06b6d4;
|
||
}}
|
||
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background-color: #f8fafc;
|
||
color: #1e293b;
|
||
}}
|
||
|
||
.navbar {{
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
|
||
}}
|
||
|
||
.stat-card {{
|
||
background: white;
|
||
border-radius: 16px;
|
||
padding: 1.5rem;
|
||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
|
||
transition: transform 0.2s;
|
||
}}
|
||
|
||
.stat-card:hover {{
|
||
transform: translateY(-2px);
|
||
}}
|
||
|
||
.stat-value {{
|
||
font-size: 2rem;
|
||
font-weight: 700;
|
||
color: var(--primary-color);
|
||
}}
|
||
|
||
.stat-label {{
|
||
color: #64748b;
|
||
font-size: 0.875rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}}
|
||
|
||
.score-excellent {{ color: var(--success-color); }}
|
||
.score-good {{ color: var(--info-color); }}
|
||
.score-average {{ color: var(--warning-color); }}
|
||
.score-poor {{ color: var(--danger-color); }}
|
||
|
||
.question-card {{
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
margin-bottom: 1.5rem;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
border-left: 4px solid var(--primary-color);
|
||
}}
|
||
|
||
.question-header {{
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 1rem;
|
||
}}
|
||
|
||
.question-number {{
|
||
background: var(--primary-color);
|
||
color: white;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 600;
|
||
margin-right: 0.75rem;
|
||
}}
|
||
|
||
.chart-container {{
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
margin-bottom: 1.5rem;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
}}
|
||
|
||
.insight-badge {{
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0.375rem 0.75rem;
|
||
border-radius: 9999px;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
margin-right: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}}
|
||
|
||
.insight-positive {{ background: #d1fae5; color: #065f46; }}
|
||
.insight-warning {{ background: #fef3c7; color: #92400e; }}
|
||
.insight-negative {{ background: #fee2e2; color: #991b1b; }}
|
||
.insight-info {{ background: #dbeafe; color: #1e40af; }}
|
||
|
||
.distribution-bar {{
|
||
height: 24px;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
overflow: hidden;
|
||
margin-top: 0.5rem;
|
||
}}
|
||
|
||
.distribution-segment {{
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
}}
|
||
|
||
.trend-indicator {{
|
||
display: inline-flex;
|
||
align-items: center;
|
||
font-size: 0.875rem;
|
||
}}
|
||
|
||
.trend-up {{ color: var(--success-color); }}
|
||
.trend-down {{ color: var(--danger-color); }}
|
||
.trend-stable {{ color: #64748b; }}
|
||
|
||
.response-sample {{
|
||
background: #f8fafc;
|
||
border-radius: 8px;
|
||
padding: 0.75rem;
|
||
margin-bottom: 0.5rem;
|
||
font-size: 0.875rem;
|
||
}}
|
||
|
||
.metric-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 1rem;
|
||
margin: 1.5rem 0;
|
||
}}
|
||
|
||
.metric-item {{
|
||
background: #f8fafc;
|
||
padding: 1rem;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
}}
|
||
|
||
.metric-value {{
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
color: var(--primary-color);
|
||
}}
|
||
|
||
.metric-label {{
|
||
font-size: 0.75rem;
|
||
color: #64748b;
|
||
text-transform: uppercase;
|
||
}}
|
||
|
||
.sticky-sidebar {{
|
||
position: sticky;
|
||
top: 20px;
|
||
}}
|
||
|
||
.nav-link {{
|
||
color: #64748b;
|
||
padding: 0.5rem 0;
|
||
border-left: 3px solid transparent;
|
||
padding-left: 1rem;
|
||
margin-left: -1rem;
|
||
}}
|
||
|
||
.nav-link:hover, .nav-link.active {{
|
||
color: var(--primary-color);
|
||
border-left-color: var(--primary-color);
|
||
}}
|
||
|
||
.comparison-table th,
|
||
.comparison-table td {{
|
||
padding: 0.75rem;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}}
|
||
|
||
.comparison-table th {{
|
||
background: #f8fafc;
|
||
font-weight: 600;
|
||
text-align: left;
|
||
}}
|
||
|
||
@media print {{
|
||
.no-print {{ display: none !important; }}
|
||
.question-card {{ break-inside: avoid; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- Navigation -->
|
||
<nav class="navbar navbar-dark no-print">
|
||
<div class="container-fluid">
|
||
<a class="navbar-brand" href="index.html">
|
||
<i class="bi bi-arrow-left me-2"></i>Back to Reports Index
|
||
</a>
|
||
<span class="navbar-text text-white">
|
||
{template_data['template_name']}
|
||
</span>
|
||
<button class="btn btn-light btn-sm" onclick="window.print()">
|
||
<i class="bi bi-printer me-2"></i>Print Report
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="container-fluid py-4">
|
||
<div class="row">
|
||
<!-- Sidebar Navigation -->
|
||
<div class="col-lg-2 d-none d-lg-block no-print">
|
||
<div class="sticky-sidebar">
|
||
<h6 class="text-uppercase text-muted mb-3">Report Sections</h6>
|
||
<nav class="nav flex-column">
|
||
<a class="nav-link" href="#overview">Overview</a>
|
||
<a class="nav-link" href="#trends">Trends</a>
|
||
<a class="nav-link" href="#questions">Questions Analysis</a>
|
||
<a class="nav-link" href="#insights">Key Insights</a>
|
||
<a class="nav-link" href="#details">Detailed Metrics</a>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content -->
|
||
<div class="col-lg-10">
|
||
<!-- Header -->
|
||
<div class="mb-4">
|
||
<h1 class="display-5 fw-bold">{template_data['template_name']}</h1>
|
||
<p class="text-muted">
|
||
<i class="bi bi-calendar3 me-2"></i>{date_range['start'][:10]} to {date_range['end'][:10]}
|
||
<span class="mx-3">|</span>
|
||
<i class="bi bi-clock me-2"></i>Generated: {generated_at[:19]}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Overview Stats -->
|
||
<section id="overview" class="mb-5">
|
||
<h2 class="h4 mb-4">Overview</h2>
|
||
<div class="row g-4">
|
||
<div class="col-md-3">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Surveys Sent</div>
|
||
<div class="stat-value">{template_data['metrics']['total_sent']:,}</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Completed</div>
|
||
<div class="stat-value">{template_data['metrics']['total_completed']:,}</div>
|
||
<small class="text-muted">{template_data['metrics']['completion_rate']}% completion</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Average Score</div>
|
||
<div class="stat-value {self._get_score_class(template_data['metrics']['avg_score'])}">
|
||
{template_data['metrics']['avg_score']}/5.0
|
||
</div>
|
||
<small class="text-muted">σ = {template_data['metrics']['std_deviation']}</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Negative Surveys</div>
|
||
<div class="stat-value text-danger">{template_data['metrics']['negative_count']:,}</div>
|
||
<small class="text-muted">{template_data['metrics']['negative_rate']}% of total</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Score Distribution -->
|
||
<div class="chart-container mt-4">
|
||
<h5 class="mb-3">Score Distribution</h5>
|
||
<div class="distribution-bar">
|
||
{self._build_distribution_bar(template_data['score_distribution'])}
|
||
</div>
|
||
<div class="row mt-3 text-center">
|
||
<div class="col-3">
|
||
<div class="text-success fw-bold">{template_data['score_distribution']['excellent']}</div>
|
||
<small class="text-muted">Excellent ({template_data['score_distribution']['excellent_percent']}%)</small>
|
||
</div>
|
||
<div class="col-3">
|
||
<div class="text-info fw-bold">{template_data['score_distribution']['good']}</div>
|
||
<small class="text-muted">Good ({template_data['score_distribution']['good_percent']}%)</small>
|
||
</div>
|
||
<div class="col-3">
|
||
<div class="text-warning fw-bold">{template_data['score_distribution']['average']}</div>
|
||
<small class="text-muted">Average ({template_data['score_distribution']['average_percent']}%)</small>
|
||
</div>
|
||
<div class="col-3">
|
||
<div class="text-danger fw-bold">{template_data['score_distribution']['poor']}</div>
|
||
<small class="text-muted">Poor ({template_data['score_distribution']['poor_percent']}%)</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Monthly Trends -->
|
||
<section id="trends" class="mb-5">
|
||
<h2 class="h4 mb-4">Monthly Trends</h2>
|
||
<div class="chart-container">
|
||
<div id="monthlyTrendsChart" style="height: 400px;"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Questions Analysis -->
|
||
<section id="questions" class="mb-5">
|
||
<h2 class="h4 mb-4">Question-Level Analysis</h2>
|
||
<p class="text-muted mb-4">
|
||
Detailed analysis of {len(template_data['questions'])} questions with statistical metrics,
|
||
score distributions, and performance trends.
|
||
</p>
|
||
{question_sections}
|
||
</section>
|
||
|
||
<!-- Key Insights -->
|
||
<section id="insights" class="mb-5">
|
||
<h2 class="h4 mb-4">Key Insights & Recommendations</h2>
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header bg-white">
|
||
<h6 class="mb-0">Top Performing Questions</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{self._build_top_questions_list(template_data['questions'], 'top')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header bg-white">
|
||
<h6 class="mb-0">Questions Needing Attention</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{self._build_top_questions_list(template_data['questions'], 'bottom')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Channel Performance -->
|
||
<section id="details" class="mb-5">
|
||
<h2 class="h4 mb-4">Channel Performance</h2>
|
||
<div class="chart-container">
|
||
<div id="channelChart" style="height: 350px;"></div>
|
||
</div>
|
||
|
||
<div class="row mt-4">
|
||
{self._build_channel_cards(template_data.get('channel_performance', {}))}
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Footer -->
|
||
<footer class="text-center text-muted py-4 border-top mt-5">
|
||
<p class="mb-0">PX360 Survey Analytics Report</p>
|
||
<small>Generated on {generated_at[:19]}</small>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
{charts_js}
|
||
|
||
// Smooth scroll for navigation
|
||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {{
|
||
anchor.addEventListener('click', function (e) {{
|
||
e.preventDefault();
|
||
document.querySelector(this.getAttribute('href')).scrollIntoView({{
|
||
behavior: 'smooth'
|
||
}});
|
||
}});
|
||
}});
|
||
|
||
// Highlight active section in navigation
|
||
const sections = document.querySelectorAll('section[id]');
|
||
const navLinks = document.querySelectorAll('.nav-link');
|
||
|
||
window.addEventListener('scroll', () => {{
|
||
let current = '';
|
||
sections.forEach(section => {{
|
||
const sectionTop = section.offsetTop;
|
||
if (scrollY >= sectionTop - 200) {{
|
||
current = section.getAttribute('id');
|
||
}}
|
||
}});
|
||
|
||
navLinks.forEach(link => {{
|
||
link.classList.remove('active');
|
||
if (link.getAttribute('href') === '#' + current) {{
|
||
link.classList.add('active');
|
||
}}
|
||
}});
|
||
}});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
def _build_question_sections(self, template_data):
|
||
"""Build detailed HTML sections for each question"""
|
||
sections = []
|
||
|
||
for i, question in enumerate(template_data['questions'], 1):
|
||
section = self._build_single_question_section(question, i)
|
||
sections.append(section)
|
||
|
||
return '\n'.join(sections)
|
||
|
||
def _build_single_question_section(self, question, index):
|
||
"""Build HTML for a single question analysis"""
|
||
question_type = question.get('question_type', 'unknown')
|
||
|
||
# Build question-specific content
|
||
if question_type in ['rating', 'likert', 'nps']:
|
||
content = self._build_numeric_question_content(question)
|
||
elif question_type in ['text', 'textarea']:
|
||
content = self._build_text_question_content(question)
|
||
elif question_type == 'multiple_choice':
|
||
content = self._build_choice_question_content(question)
|
||
else:
|
||
content = '<p class="text-muted">Detailed analysis not available for this question type.</p>'
|
||
|
||
# Score class for color coding
|
||
avg_score = question.get('avg_score', 0)
|
||
score_class = self._get_score_class(avg_score)
|
||
|
||
return f"""
|
||
<div class="question-card" id="question_{index}">
|
||
<div class="question-header">
|
||
<div class="d-flex align-items-start">
|
||
<div class="question-number">{index}</div>
|
||
<div>
|
||
<h5 class="mb-1">{question.get('question_text', 'Untitled Question')}</h5>
|
||
{f'<p class="text-muted mb-0 small">{question.get("question_text_ar", "")}</p>' if question.get('question_text_ar') else ''}
|
||
<div class="mt-2">
|
||
<span class="badge bg-secondary">{question_type.upper()}</span>
|
||
{f'<span class="badge bg-warning text-dark">Required</span>' if question.get('is_required') else ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-end">
|
||
<div class="stat-value {score_class}">{avg_score if avg_score else 'N/A'}</div>
|
||
<small class="text-muted">Average Score</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mt-4">
|
||
<div class="col-md-8">
|
||
{content}
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="metric-grid" style="margin-top: 0;">
|
||
<div class="metric-item">
|
||
<div class="metric-value">{question.get('response_count', 0):,}</div>
|
||
<div class="metric-label">Responses</div>
|
||
</div>
|
||
<div class="metric-item">
|
||
<div class="metric-value">{question.get('response_rate', 0)}%</div>
|
||
<div class="metric-label">Response Rate</div>
|
||
</div>
|
||
{f'''
|
||
<div class="metric-item">
|
||
<div class="metric-value">{question.get('std_deviation', 0)}</div>
|
||
<div class="metric-label">Std Deviation</div>
|
||
</div>
|
||
<div class="metric-item">
|
||
<div class="metric-value">{question.get('correlation_with_overall', 0)}</div>
|
||
<div class="metric-label">Correlation</div>
|
||
</div>
|
||
''' if question_type in ['rating', 'likert', 'nps'] else ''}
|
||
{f'''
|
||
<div class="metric-item">
|
||
<div class="metric-value">{question.get('text_response_count', 0)}</div>
|
||
<div class="metric-label">Text Responses</div>
|
||
</div>
|
||
<div class="metric-item">
|
||
<div class="metric-value">{question.get('avg_text_length', 0):.0f}</div>
|
||
<div class="metric-label">Avg Length</div>
|
||
</div>
|
||
''' if question_type in ['text', 'textarea'] else ''}
|
||
</div>
|
||
|
||
{self._build_question_insights(question)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
def _build_numeric_question_content(self, question):
|
||
"""Build content for numeric/rating questions"""
|
||
score_dist = question.get('score_distribution', {})
|
||
monthly_trends = question.get('monthly_trends', [])
|
||
|
||
# Build distribution bar
|
||
dist_html = '<div class="mb-3">'
|
||
dist_html += '<h6 class="text-muted">Score Distribution</h6>'
|
||
dist_html += '<div class="distribution-bar" style="height: 32px;">'
|
||
|
||
colors = {1: '#ef4444', 2: '#f97316', 3: '#f59e0b', 4: '#10b981', 5: '#059669'}
|
||
|
||
for score in [5, 4, 3, 2, 1]:
|
||
count = score_dist.get(f'score_{score}', 0)
|
||
percent = score_dist.get(f'score_{score}_percent', 0)
|
||
if count > 0:
|
||
dist_html += f'''
|
||
<div class="distribution-segment" style="width: {percent}%; background: {colors[score]};">
|
||
{f'{count}' if percent > 8 else ''}
|
||
</div>
|
||
'''
|
||
dist_html += '</div>'
|
||
dist_html += '<div class="d-flex justify-content-between text-center mt-1">'
|
||
for score in [1, 2, 3, 4, 5]:
|
||
count = score_dist.get(f'score_{score}', 0)
|
||
percent = score_dist.get(f'score_{score}_percent', 0)
|
||
dist_html += f'<small class="text-muted">{score}: {count} ({percent}%)</small>'
|
||
dist_html += '</div></div>'
|
||
|
||
# Add monthly trends chart placeholder
|
||
dist_html += f'''
|
||
<div class="mt-3">
|
||
<h6 class="text-muted">Monthly Trends</h6>
|
||
<div id="questionChart_{question.get('question_id', 'unknown')}" style="height: 200px;"></div>
|
||
</div>
|
||
'''
|
||
|
||
return dist_html
|
||
|
||
def _build_text_question_content(self, question):
|
||
"""Build content for text questions"""
|
||
return f"""
|
||
<div class="alert alert-info">
|
||
<i class="bi bi-info-circle me-2"></i>
|
||
This is a text-based question. {question.get('text_response_count', 0)} patients provided written feedback.
|
||
Average response length: {question.get('avg_text_length', 0):.0f} characters.
|
||
</div>
|
||
<p class="text-muted">
|
||
<small>Review individual survey responses to read patient comments for this question.</small>
|
||
</p>
|
||
"""
|
||
|
||
def _build_choice_question_content(self, question):
|
||
"""Build content for multiple choice questions"""
|
||
choice_dist = question.get('choice_distribution', {})
|
||
|
||
html = '<h6 class="text-muted">Choice Distribution</h6>'
|
||
html += '<table class="table table-sm">'
|
||
html += '<thead><tr><th>Choice</th><th>Count</th><th>Percentage</th></tr></thead><tbody>'
|
||
|
||
for choice, count in sorted(choice_dist.items(), key=lambda x: x[1], reverse=True):
|
||
if not choice.endswith('_percent'):
|
||
percent = choice_dist.get(f'{choice}_percent', 0)
|
||
html += f'<tr><td>{choice}</td><td>{count}</td><td>{percent}%</td></tr>'
|
||
|
||
html += '</tbody></table>'
|
||
return html
|
||
|
||
def _build_question_insights(self, question):
|
||
"""Generate insights for a question"""
|
||
insights = []
|
||
question_type = question.get('question_type', '')
|
||
|
||
if question_type in ['rating', 'likert', 'nps']:
|
||
avg_score = question.get('avg_score', 0)
|
||
response_rate = question.get('response_rate', 0)
|
||
correlation = question.get('correlation_with_overall', 0)
|
||
|
||
if avg_score >= 4.5:
|
||
insights.append(('<span class="insight-badge insight-positive"><i class="bi bi-trophy me-1"></i>Excellent Performance</span>', ''))
|
||
elif avg_score < 3.0:
|
||
insights.append(('<span class="insight-badge insight-negative"><i class="bi bi-exclamation-triangle me-1"></i>Needs Improvement</span>', ''))
|
||
|
||
if response_rate < 70:
|
||
insights.append(('<span class="insight-badge insight-warning"><i class="bi bi-eye-slash me-1"></i>Low Response Rate</span>', ''))
|
||
|
||
if abs(correlation) > 0.7:
|
||
direction = 'positively' if correlation > 0 else 'negatively'
|
||
insights.append((f'<span class="insight-badge insight-info"><i class="bi bi-link-45deg me-1"></i>Strong {direction} correlates with overall</span>', ''))
|
||
|
||
if insights:
|
||
return '<div class="mt-3">' + ''.join([i[0] for i in insights]) + '</div>'
|
||
return ''
|
||
|
||
def _build_question_charts_js(self, template_data):
|
||
"""Build JavaScript for all question charts"""
|
||
js = []
|
||
|
||
# Monthly trends chart for the survey
|
||
monthly_data = template_data.get('monthly_trends', [])
|
||
months = [t['month_name'] for t in monthly_data]
|
||
scores = [t['avg_score'] for t in monthly_data]
|
||
counts = [t['count'] for t in monthly_data]
|
||
|
||
js.append(f"""
|
||
// Monthly trends for survey
|
||
new ApexCharts(document.querySelector("#monthlyTrendsChart"), {{
|
||
series: [
|
||
{{ name: 'Average Score', type: 'line', data: {scores} }},
|
||
{{ name: 'Survey Count', type: 'column', data: {counts} }}
|
||
],
|
||
chart: {{
|
||
height: 400,
|
||
type: 'line',
|
||
toolbar: {{ show: true }}
|
||
}},
|
||
stroke: {{ width: [3, 0] }},
|
||
xaxis: {{ categories: {months} }},
|
||
yaxis: [
|
||
{{ title: {{ text: 'Average Score' }}, min: 0, max: 5 }},
|
||
{{ opposite: true, title: {{ text: 'Count' }} }}
|
||
],
|
||
colors: ['#2563eb', '#10b981'],
|
||
title: {{ text: 'Monthly Survey Performance' }}
|
||
}}).render();
|
||
""")
|
||
|
||
# Channel performance chart
|
||
channel_perf = template_data.get('channel_performance', {})
|
||
if channel_perf:
|
||
channels = list(channel_perf.keys())
|
||
completion_rates = [channel_perf.get(ch, {}).get('completion_rate', 0) for ch in channels]
|
||
avg_scores = [channel_perf.get(ch, {}).get('avg_score', 0) for ch in channels]
|
||
|
||
js.append(f"""
|
||
// Channel performance chart
|
||
new ApexCharts(document.querySelector("#channelChart"), {{
|
||
series: [
|
||
{{ name: 'Completion Rate (%)', data: {completion_rates} }},
|
||
{{ name: 'Average Score', data: {avg_scores} }}
|
||
],
|
||
chart: {{
|
||
type: 'bar',
|
||
height: 350
|
||
}},
|
||
xaxis: {{ categories: {channels} }},
|
||
colors: ['#2563eb', '#10b981'],
|
||
title: {{ text: 'Performance by Channel' }}
|
||
}}).render();
|
||
""")
|
||
|
||
# Question-level trend charts
|
||
for question in template_data.get('questions', []):
|
||
if question.get('question_type') in ['rating', 'likert', 'nps']:
|
||
monthly_trends = question.get('monthly_trends', [])
|
||
if monthly_trends:
|
||
months = [t['month_name'] for t in monthly_trends]
|
||
scores = [t['avg_score'] for t in monthly_trends]
|
||
|
||
js.append(f"""
|
||
new ApexCharts(document.querySelector("#questionChart_{question.get('question_id', 'unknown')}"), {{
|
||
series: [{{ data: {scores} }}],
|
||
chart: {{ type: 'area', height: 200, sparkline: {{ enabled: true }} }},
|
||
stroke: {{ curve: 'smooth', width: 2 }},
|
||
fill: {{ opacity: 0.3 }},
|
||
colors: ['#2563eb'],
|
||
tooltip: {{ fixed: {{ enabled: false }}, x: {{ show: false }} }}
|
||
}}).render();
|
||
""")
|
||
|
||
return '\n'.join(js)
|
||
|
||
def _build_top_questions_list(self, questions, mode='top'):
|
||
"""Build list of top/bottom performing questions"""
|
||
numeric_questions = [q for q in questions if q.get('avg_score') is not None]
|
||
|
||
if not numeric_questions:
|
||
return '<p class="text-muted">No numeric questions available.</p>'
|
||
|
||
sorted_questions = sorted(numeric_questions, key=lambda x: x.get('avg_score', 0), reverse=(mode == 'top'))
|
||
selected = sorted_questions[:5]
|
||
|
||
html = '<ul class="list-group list-group-flush">'
|
||
for q in selected:
|
||
score = q.get('avg_score', 0)
|
||
score_class = self._get_score_class(score)
|
||
html += f'''
|
||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||
<span class="text-truncate" style="max-width: 70%;" title="{q.get('question_text', '')}">
|
||
Q{q.get('order', 0)}: {q.get('question_text', '')}
|
||
</span>
|
||
<span class="badge {score_class.replace('score-', 'bg-')} rounded-pill">{score}</span>
|
||
</li>
|
||
'''
|
||
html += '</ul>'
|
||
return html
|
||
|
||
def _build_channel_cards(self, channel_perf):
|
||
"""Build channel performance cards"""
|
||
cards = []
|
||
channel_icons = {
|
||
'sms': 'bi-phone',
|
||
'whatsapp': 'bi-whatsapp',
|
||
'email': 'bi-envelope'
|
||
}
|
||
|
||
for channel, data in channel_perf.items():
|
||
icon = channel_icons.get(channel, 'bi-send')
|
||
cards.append(f'''
|
||
<div class="col-md-4">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-body text-center">
|
||
<i class="bi {icon} fs-1 text-primary mb-3"></i>
|
||
<h5>{channel.upper()}</h5>
|
||
<div class="row mt-3">
|
||
<div class="col-6">
|
||
<div class="text-muted small">Sent</div>
|
||
<div class="fw-bold">{data.get('total_sent', 0)}</div>
|
||
</div>
|
||
<div class="col-6">
|
||
<div class="text-muted small">Completed</div>
|
||
<div class="fw-bold">{data.get('completed', 0)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2">
|
||
<span class="badge bg-success">{data.get('completion_rate', 0)}% completion</span>
|
||
</div>
|
||
<div class="mt-1">
|
||
<small class="text-muted">Avg Score: {data.get('avg_score', 0)}</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
''')
|
||
|
||
return '\n'.join(cards) if cards else '<div class="col-12"><p class="text-muted">No channel data available.</p></div>'
|
||
|
||
def _build_distribution_bar(self, score_dist):
|
||
"""Build HTML for score distribution bar"""
|
||
total = sum([
|
||
score_dist.get('excellent', 0),
|
||
score_dist.get('good', 0),
|
||
score_dist.get('average', 0),
|
||
score_dist.get('poor', 0)
|
||
])
|
||
|
||
if total == 0:
|
||
return '<div style="width: 100%; background: #e2e8f0;">No Data</div>'
|
||
|
||
segments = []
|
||
colors = {
|
||
'excellent': '#10b981',
|
||
'good': '#06b6d4',
|
||
'average': '#f59e0b',
|
||
'poor': '#ef4444'
|
||
}
|
||
|
||
for key in ['excellent', 'good', 'average', 'poor']:
|
||
count = score_dist.get(key, 0)
|
||
if count > 0:
|
||
percent = (count / total) * 100
|
||
segments.append(f'<div class="distribution-segment" style="width: {percent}%; background: {colors[key]};"></div>')
|
||
|
||
return '\n'.join(segments)
|
||
|
||
def _get_score_class(self, score):
|
||
"""Get CSS class based on score"""
|
||
if score is None:
|
||
return 'text-muted'
|
||
if score >= 4.5:
|
||
return 'score-excellent'
|
||
elif score >= 3.5:
|
||
return 'score-good'
|
||
elif score >= 2.5:
|
||
return 'score-average'
|
||
else:
|
||
return 'score-poor'
|
||
|
||
def _generate_master_index(self, reports, summary, date_range, generated_at):
|
||
"""Generate master index HTML linking all reports"""
|
||
|
||
report_links = []
|
||
for report in reports:
|
||
report_links.append(f'''
|
||
<div class="col-md-6 col-lg-4 mb-4">
|
||
<div class="card h-100 border-0 shadow-sm">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center mb-3">
|
||
<div class="bg-primary bg-opacity-10 rounded-circle p-3 me-3">
|
||
<i class="bi bi-file-earmark-bar-graph text-primary fs-4"></i>
|
||
</div>
|
||
<div>
|
||
<h5 class="card-title mb-0">{report['template_name']}</h5>
|
||
<small class="text-muted">{self._human_readable_size(report['size'])}</small>
|
||
</div>
|
||
</div>
|
||
<a href="{report['filename']}" class="btn btn-primary w-100">
|
||
<i class="bi bi-eye me-2"></i>View Report
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
''')
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Survey Analytics Reports Index - PX360</title>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||
<style>
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background-color: #f8fafc;
|
||
}}
|
||
.hero {{
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 4rem 0;
|
||
}}
|
||
.stat-card {{
|
||
background: white;
|
||
border-radius: 16px;
|
||
padding: 1.5rem;
|
||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="hero">
|
||
<div class="container">
|
||
<h1 class="display-4 fw-bold mb-3">📊 Survey Analytics Reports</h1>
|
||
<p class="lead mb-0">
|
||
Generated: {generated_at[:19]}<br>
|
||
Period: {date_range['start'][:10]} to {date_range['end'][:10]}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container py-5">
|
||
<!-- Summary Stats -->
|
||
<div class="row g-4 mb-5">
|
||
<div class="col-md-3">
|
||
<div class="stat-card text-center">
|
||
<div class="display-4 fw-bold text-primary">{summary['total_templates']}</div>
|
||
<div class="text-muted">Survey Templates</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-card text-center">
|
||
<div class="display-4 fw-bold text-success">{summary['total_surveys_sent']:,}</div>
|
||
<div class="text-muted">Surveys Sent</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-card text-center">
|
||
<div class="display-4 fw-bold text-info">{summary['total_surveys_completed']:,}</div>
|
||
<div class="text-muted">Completed</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-card text-center">
|
||
<div class="display-4 fw-bold text-warning">{summary['overall_completion_rate']}%</div>
|
||
<div class="text-muted">Completion Rate</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Individual Reports -->
|
||
<h2 class="h3 mb-4">Available Reports</h2>
|
||
<div class="row">
|
||
{''.join(report_links)}
|
||
</div>
|
||
|
||
<!-- Summary Info -->
|
||
<div class="card border-0 shadow-sm mt-5">
|
||
<div class="card-body">
|
||
<h5 class="card-title">Report Summary</h5>
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<table class="table table-borderless">
|
||
<tr>
|
||
<th>Overall Average Score:</th>
|
||
<td class="fw-bold">{summary['overall_avg_score']}/5.0</td>
|
||
</tr>
|
||
<tr>
|
||
<th>Negative Survey Rate:</th>
|
||
<td class="text-danger">{summary['overall_negative_rate']}%</td>
|
||
</tr>
|
||
<tr>
|
||
<th>Best Performing:</th>
|
||
<td>{summary['best_performing_template']['name']} ({summary['best_performing_template']['avg_score']})</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<table class="table table-borderless">
|
||
<tr>
|
||
<th>Total Reports:</th>
|
||
<td>{len(reports)}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>Report Directory:</th>
|
||
<td><code>reports/</code></td>
|
||
</tr>
|
||
<tr>
|
||
<th>Worst Performing:</th>
|
||
<td>{summary['worst_performing_template']['name']} ({summary['worst_performing_template']['avg_score']})</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="text-center text-muted py-5">
|
||
<p>PX360 Survey Analytics System</p>
|
||
</footer>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
def _human_readable_size(self, size_bytes):
|
||
"""Convert bytes to human readable format"""
|
||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||
if size_bytes < 1024.0:
|
||
return f"{size_bytes:.1f} {unit}"
|
||
size_bytes /= 1024.0
|
||
return f"{size_bytes:.1f} TB"
|
||
|
||
|
||
# ============================================================================
|
||
# CONVENIENCE FUNCTIONS FOR ENHANCED REPORTS
|
||
# ============================================================================
|
||
|
||
def generate_enhanced_survey_reports(template_name=None, start_date=None, end_date=None, output_dir=None):
|
||
"""
|
||
Generate enhanced survey analytics reports - one per survey template.
|
||
|
||
Args:
|
||
template_name: Optional filter by survey template name
|
||
start_date: Optional start date
|
||
end_date: Optional end date
|
||
output_dir: Directory to save reports (default: settings.SURVEY_REPORTS_DIR or 'reports')
|
||
|
||
Returns:
|
||
dict: Paths to generated reports
|
||
"""
|
||
from django.conf import settings
|
||
import os
|
||
|
||
if output_dir is None:
|
||
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
|
||
|
||
# Calculate analytics
|
||
analytics_data = calculate_survey_analytics(template_name, start_date, end_date)
|
||
|
||
# Generate reports
|
||
generator = MultiReportGenerator(output_dir)
|
||
result = generator.generate_reports(analytics_data)
|
||
|
||
return result
|
||
|
||
|
||
def generate_single_template_report(template_id, start_date=None, end_date=None):
|
||
"""
|
||
Generate a detailed report for a single survey template.
|
||
|
||
Args:
|
||
template_id: UUID of the survey template
|
||
start_date: Optional start date
|
||
end_date: Optional end date
|
||
|
||
Returns:
|
||
str: HTML content of the report
|
||
"""
|
||
from apps.surveys.models import SurveyTemplate
|
||
|
||
template = SurveyTemplate.objects.get(id=template_id)
|
||
|
||
# Calculate analytics for this specific template
|
||
analytics_data = calculate_survey_analytics(
|
||
template_name=template.name,
|
||
start_date=start_date,
|
||
end_date=end_date
|
||
)
|
||
|
||
# Generate single report
|
||
generator = MultiReportGenerator('/tmp')
|
||
|
||
# Find the template data
|
||
template_data = None
|
||
for t in analytics_data['templates']:
|
||
if t['template_id'] == str(template_id):
|
||
template_data = t
|
||
break
|
||
|
||
if not template_data:
|
||
raise ValueError(f"No data found for template {template_id}")
|
||
|
||
return generator._generate_single_template_report(
|
||
template_data,
|
||
analytics_data['date_range'],
|
||
analytics_data['report_generated_at']
|
||
)
|