HH/apps/surveys/ui_views.py

789 lines
28 KiB
Python

"""
Survey Console UI views - Server-rendered templates for survey management
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, Prefetch, Avg, Count, F, Case, When, IntegerField
from django.db.models.functions import TruncDate
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.db.models import ExpressionWrapper, FloatField
from apps.core.services import AuditService
from apps.organizations.models import Department, Hospital
from .forms import SurveyQuestionFormSet, SurveyTemplateForm
from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
from .tasks import send_satisfaction_feedback
@login_required
def survey_instance_list(request):
"""
Survey instances list view with filters.
Features:
- Server-side pagination
- Filters (status, journey type, hospital, date range)
- Search by patient MRN
- Score display
"""
# Base queryset with optimizations
queryset = SurveyInstance.objects.select_related(
'survey_template',
'patient',
'journey_instance__journey_template'
).prefetch_related(
'responses__question'
)
# Apply RBAC filters
user = request.user
if user.is_px_admin():
pass # See all
elif user.is_hospital_admin() and user.hospital:
queryset = queryset.filter(survey_template__hospital=user.hospital)
elif user.hospital:
queryset = queryset.filter(survey_template__hospital=user.hospital)
else:
queryset = queryset.none()
# Apply filters
status_filter = request.GET.get('status')
if status_filter:
queryset = queryset.filter(status=status_filter)
survey_type = request.GET.get('survey_type')
if survey_type:
queryset = queryset.filter(survey_template__survey_type=survey_type)
is_negative = request.GET.get('is_negative')
if is_negative == 'true':
queryset = queryset.filter(is_negative=True)
hospital_filter = request.GET.get('hospital')
if hospital_filter:
queryset = queryset.filter(survey_template__hospital_id=hospital_filter)
# Search
search_query = request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(patient__mrn__icontains=search_query) |
Q(patient__first_name__icontains=search_query) |
Q(patient__last_name__icontains=search_query) |
Q(encounter_id__icontains=search_query)
)
# Date range
date_from = request.GET.get('date_from')
if date_from:
queryset = queryset.filter(sent_at__gte=date_from)
date_to = request.GET.get('date_to')
if date_to:
queryset = queryset.filter(sent_at__lte=date_to)
# Ordering
order_by = request.GET.get('order_by', '-created_at')
queryset = queryset.order_by(order_by)
# Pagination
page_size = int(request.GET.get('page_size', 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Get base queryset for statistics (without pagination)
stats_queryset = SurveyInstance.objects.select_related('survey_template')
# Apply same RBAC filters
if user.is_px_admin():
pass
elif user.is_hospital_admin() and user.hospital:
stats_queryset = stats_queryset.filter(survey_template__hospital=user.hospital)
elif user.hospital:
stats_queryset = stats_queryset.filter(survey_template__hospital=user.hospital)
else:
stats_queryset = stats_queryset.none()
# Apply same filters to stats
if status_filter:
stats_queryset = stats_queryset.filter(status=status_filter)
if survey_type:
stats_queryset = stats_queryset.filter(survey_template__survey_type=survey_type)
if is_negative == 'true':
stats_queryset = stats_queryset.filter(is_negative=True)
if hospital_filter:
stats_queryset = stats_queryset.filter(survey_template__hospital_id=hospital_filter)
if search_query:
stats_queryset = stats_queryset.filter(
Q(patient__mrn__icontains=search_query) |
Q(patient__first_name__icontains=search_query) |
Q(patient__last_name__icontains=search_query) |
Q(encounter_id__icontains=search_query)
)
if date_from:
stats_queryset = stats_queryset.filter(sent_at__gte=date_from)
if date_to:
stats_queryset = stats_queryset.filter(sent_at__lte=date_to)
# Statistics
total_count = stats_queryset.count()
# Include both 'sent' and 'pending' statuses for sent count
sent_count = stats_queryset.filter(status__in=['sent', 'pending']).count()
completed_count = stats_queryset.filter(status='completed').count()
negative_count = stats_queryset.filter(is_negative=True).count()
# Tracking statistics
opened_count = stats_queryset.filter(open_count__gt=0).count()
in_progress_count = stats_queryset.filter(status='in_progress').count()
abandoned_count = stats_queryset.filter(status='abandoned').count()
viewed_count = stats_queryset.filter(status='viewed').count()
pending_count = stats_queryset.filter(status='pending').count()
# Time metrics
completed_surveys = stats_queryset.filter(
status='completed',
time_spent_seconds__isnull=False
)
avg_completion_time = completed_surveys.aggregate(
avg_time=Avg('time_spent_seconds')
)['avg_time'] or 0
# Time to first open
surveys_with_open = stats_queryset.filter(
opened_at__isnull=False,
sent_at__isnull=False
)
if surveys_with_open.exists():
# Calculate average time to open
total_time_to_open = 0
count = 0
for survey in surveys_with_open:
if survey.opened_at and survey.sent_at:
total_time_to_open += (survey.opened_at - survey.sent_at).total_seconds()
count += 1
avg_time_to_open = total_time_to_open / count if count > 0 else 0
else:
avg_time_to_open = 0
stats = {
'total': total_count,
'sent': sent_count,
'completed': completed_count,
'negative': negative_count,
'response_rate': round((completed_count / total_count * 100) if total_count > 0 else 0, 1),
# New tracking stats
'opened': opened_count,
'open_rate': round((opened_count / sent_count * 100) if sent_count > 0 else 0, 1),
'in_progress': in_progress_count,
'abandoned': abandoned_count,
'viewed': viewed_count,
'pending': pending_count,
'avg_completion_time': int(avg_completion_time),
'avg_time_to_open': int(avg_time_to_open),
}
# Score Distribution
score_distribution = []
score_ranges = [
('1-2', 1, 2),
('2-3', 2, 3),
('3-4', 3, 4),
('4-5', 4, 5),
]
for label, min_score, max_score in score_ranges:
# Use lte for the highest range to include exact match
if max_score == 5:
count = stats_queryset.filter(
total_score__gte=min_score,
total_score__lte=max_score
).count()
else:
count = stats_queryset.filter(
total_score__gte=min_score,
total_score__lt=max_score
).count()
score_distribution.append({
'range': label,
'count': count,
'percentage': round((count / total_count * 100) if total_count > 0 else 0, 1)
})
# Engagement Funnel Data - Include viewed and pending stages
engagement_funnel = [
{'stage': 'Sent/Pending', 'count': sent_count, 'percentage': 100},
{'stage': 'Viewed', 'count': viewed_count, 'percentage': round((viewed_count / sent_count * 100) if sent_count > 0 else 0, 1)},
{'stage': 'Opened', 'count': opened_count, 'percentage': round((opened_count / sent_count * 100) if sent_count > 0 else 0, 1)},
{'stage': 'In Progress', 'count': in_progress_count, 'percentage': round((in_progress_count / opened_count * 100) if opened_count > 0 else 0, 1)},
{'stage': 'Completed', 'count': completed_count, 'percentage': round((completed_count / sent_count * 100) if sent_count > 0 else 0, 1)},
]
# Completion Time Distribution
completion_time_ranges = [
('< 1 min', 0, 60),
('1-5 min', 60, 300),
('5-10 min', 300, 600),
('10-20 min', 600, 1200),
('20+ min', 1200, float('inf')),
]
completion_time_distribution = []
for label, min_seconds, max_seconds in completion_time_ranges:
if max_seconds == float('inf'):
count = completed_surveys.filter(time_spent_seconds__gte=min_seconds).count()
else:
count = completed_surveys.filter(
time_spent_seconds__gte=min_seconds,
time_spent_seconds__lt=max_seconds
).count()
completion_time_distribution.append({
'range': label,
'count': count,
'percentage': round((count / completed_count * 100) if completed_count > 0 else 0, 1)
})
# Device Type Distribution
device_distribution = []
from .models import SurveyTracking
tracking_events = SurveyTracking.objects.filter(
survey_instance__in=stats_queryset
).values('device_type').annotate(
count=Count('id')
).order_by('-count')
device_mapping = {
'mobile': 'Mobile',
'tablet': 'Tablet',
'desktop': 'Desktop',
}
for entry in tracking_events:
device_key = entry['device_type']
device_name = device_mapping.get(device_key, device_key.title())
count = entry['count']
percentage = round((count / tracking_events.count() * 100) if tracking_events.count() > 0 else 0, 1)
device_distribution.append({
'type': device_key,
'name': device_name,
'count': count,
'percentage': percentage
})
# Survey Trend (last 30 days) - Use created_at if sent_at is missing
from django.utils import timezone
import datetime
thirty_days_ago = timezone.now() - datetime.timedelta(days=30)
# Try sent_at first, fall back to created_at if sent_at is null
trend_queryset = stats_queryset.filter(
sent_at__gte=thirty_days_ago
)
# If no surveys with sent_at in last 30 days, try created_at
if not trend_queryset.exists():
trend_queryset = stats_queryset.filter(
created_at__gte=thirty_days_ago
).annotate(
date=TruncDate('created_at')
)
else:
trend_queryset = trend_queryset.annotate(
date=TruncDate('sent_at')
)
trend_data = trend_queryset.values('date').annotate(
sent=Count('id'),
completed=Count('id', filter=Q(status='completed'))
).order_by('date')
trend_labels = []
trend_sent = []
trend_completed = []
for entry in trend_data:
if entry['date']:
trend_labels.append(entry['date'].strftime('%Y-%m-%d'))
trend_sent.append(entry['sent'])
trend_completed.append(entry['completed'])
# Survey Type Distribution
survey_type_data = stats_queryset.values(
'survey_template__survey_type'
).annotate(
count=Count('id')
).order_by('-count')
survey_types = []
survey_type_labels = []
survey_type_counts = []
survey_type_mapping = {
'stage': 'Journey Stage',
'complaint_resolution': 'Complaint Resolution',
'general': 'General',
'nps': 'NPS',
}
for entry in survey_type_data:
type_key = entry['survey_template__survey_type']
type_name = survey_type_mapping.get(type_key, type_key.title())
count = entry['count']
percentage = round((count / total_count * 100) if total_count > 0 else 0, 1)
survey_types.append({
'type': type_key,
'name': type_name,
'count': count,
'percentage': percentage
})
survey_type_labels.append(type_name)
survey_type_counts.append(count)
# Serialize chart data to JSON for clean JavaScript usage
import json
context = {
'page_obj': page_obj,
'surveys': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'filters': request.GET,
# Visualization data as JSON for clean JavaScript
'engagement_funnel_json': json.dumps(engagement_funnel),
'completion_time_distribution_json': json.dumps(completion_time_distribution),
'device_distribution_json': json.dumps(device_distribution),
'score_distribution_json': json.dumps(score_distribution),
'survey_types_json': json.dumps(survey_types),
'trend_labels_json': json.dumps(trend_labels),
'trend_sent_json': json.dumps(trend_sent),
'trend_completed_json': json.dumps(trend_completed),
}
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.info(f"=== CHART DATA DEBUG ===")
logger.info(f"Score Distribution: {score_distribution}")
logger.info(f"Engagement Funnel: {engagement_funnel}")
logger.info(f"Completion Time Distribution: {completion_time_distribution}")
logger.info(f"Device Distribution: {device_distribution}")
logger.info(f"Total surveys in stats_queryset: {total_count}")
return render(request, 'surveys/instance_list.html', context)
@login_required
def survey_instance_detail(request, pk):
"""
Survey instance detail view with responses.
Features:
- Full survey details
- All responses
- Score breakdown
- Related journey/stage info
- Score comparison with template average
- Related surveys from same patient
"""
survey = get_object_or_404(
SurveyInstance.objects.select_related(
'survey_template',
'patient',
'journey_instance__journey_template'
).prefetch_related(
'responses__question'
),
pk=pk
)
# Get responses
responses = survey.responses.all().order_by('question__order')
# Calculate average score for this survey template
template_average = SurveyInstance.objects.filter(
survey_template=survey.survey_template,
status='completed'
).aggregate(
avg_score=Avg('total_score')
)['avg_score'] or 0
# Get related surveys from the same patient
related_surveys = SurveyInstance.objects.filter(
patient=survey.patient,
status='completed'
).exclude(
id=survey.id
).select_related(
'survey_template'
).order_by('-completed_at')[:5]
# Get response statistics for each question (for choice questions)
question_stats = {}
for response in responses:
if response.question.question_type in ['multiple_choice', 'single_choice']:
choice_responses = SurveyInstance.objects.filter(
survey_template=survey.survey_template,
status='completed'
).values(
f'responses__choice_value'
).annotate(
count=Count('id')
).filter(
responses__question=response.question
).order_by('-count')
question_stats[response.question.id] = {
'type': 'choice',
'options': [
{
'value': opt['responses__choice_value'],
'count': opt['count'],
'percentage': round((opt['count'] / choice_responses.count() * 100) if choice_responses.count() > 0 else 0, 1)
}
for opt in choice_responses
if opt['responses__choice_value']
]
}
elif response.question.question_type == 'rating':
rating_stats = SurveyInstance.objects.filter(
survey_template=survey.survey_template,
status='completed'
).aggregate(
avg_rating=Avg('responses__numeric_value'),
total_responses=Count('responses')
)
question_stats[response.question.id] = {
'type': 'rating',
'average': round(rating_stats['avg_rating'] or 0, 2),
'total_responses': rating_stats['total_responses'] or 0
}
context = {
'survey': survey,
'responses': responses,
'template_average': round(template_average, 2),
'related_surveys': related_surveys,
'question_stats': question_stats,
}
return render(request, 'surveys/instance_detail.html', context)
@login_required
def survey_template_list(request):
"""Survey templates list view"""
queryset = SurveyTemplate.objects.select_related('hospital').prefetch_related('questions')
# Apply RBAC filters
user = request.user
if user.is_px_admin():
pass
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# Apply filters
survey_type = request.GET.get('survey_type')
if survey_type:
queryset = queryset.filter(survey_type=survey_type)
hospital_filter = request.GET.get('hospital')
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
is_active = request.GET.get('is_active')
if is_active == 'true':
queryset = queryset.filter(is_active=True)
elif is_active == 'false':
queryset = queryset.filter(is_active=False)
# Ordering
queryset = queryset.order_by('hospital', 'survey_type', 'name')
# Pagination
page_size = int(request.GET.get('page_size', 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
context = {
'page_obj': page_obj,
'templates': page_obj.object_list,
'hospitals': hospitals,
'filters': request.GET,
}
return render(request, 'surveys/template_list.html', context)
@login_required
def survey_template_create(request):
"""Create a new survey template with questions"""
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to create survey templates.")
return redirect('surveys:template_list')
if request.method == 'POST':
form = SurveyTemplateForm(request.POST)
formset = SurveyQuestionFormSet(request.POST)
if form.is_valid() and formset.is_valid():
template = form.save(commit=False)
template.created_by = user
template.save()
questions = formset.save(commit=False)
for question in questions:
question.survey_template = template
question.save()
messages.success(request, "Survey template created successfully.")
return redirect('surveys:template_detail', pk=template.pk)
else:
form = SurveyTemplateForm()
formset = SurveyQuestionFormSet()
context = {
'form': form,
'formset': formset,
}
return render(request, 'surveys/template_form.html', context)
@login_required
def survey_template_detail(request, pk):
"""View survey template details"""
template = get_object_or_404(
SurveyTemplate.objects.select_related('hospital').prefetch_related('questions'),
pk=pk
)
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
if user.hospital and template.hospital != user.hospital:
messages.error(request, "You don't have permission to view this template.")
return redirect('surveys:template_list')
# Get statistics
total_instances = template.instances.count()
completed_instances = template.instances.filter(status='completed').count()
negative_instances = template.instances.filter(is_negative=True).count()
avg_score = template.instances.filter(status='completed').aggregate(
avg_score=Avg('total_score')
)['avg_score'] or 0
context = {
'template': template,
'questions': template.questions.all().order_by('order'),
'stats': {
'total_instances': total_instances,
'completed_instances': completed_instances,
'negative_instances': negative_instances,
'completion_rate': round((completed_instances / total_instances * 100) if total_instances > 0 else 0, 1),
'avg_score': round(avg_score, 2),
}
}
return render(request, 'surveys/template_detail.html', context)
@login_required
def survey_template_edit(request, pk):
"""Edit an existing survey template with questions"""
template = get_object_or_404(SurveyTemplate, pk=pk)
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
if user.hospital and template.hospital != user.hospital:
messages.error(request, "You don't have permission to edit this template.")
return redirect('surveys:template_list')
if request.method == 'POST':
form = SurveyTemplateForm(request.POST, instance=template)
formset = SurveyQuestionFormSet(request.POST, instance=template)
if form.is_valid() and formset.is_valid():
form.save()
formset.save()
messages.success(request, "Survey template updated successfully.")
return redirect('surveys:template_detail', pk=template.pk)
else:
form = SurveyTemplateForm(instance=template)
formset = SurveyQuestionFormSet(instance=template)
context = {
'form': form,
'formset': formset,
'template': template,
}
return render(request, 'surveys/template_form.html', context)
@login_required
def survey_template_delete(request, pk):
"""Delete a survey template"""
template = get_object_or_404(SurveyTemplate, pk=pk)
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
if user.hospital and template.hospital != user.hospital:
messages.error(request, "You don't have permission to delete this template.")
return redirect('surveys:template_list')
if request.method == 'POST':
template_name = template.name
template.delete()
messages.success(request, f"Survey template '{template_name}' deleted successfully.")
return redirect('surveys:template_list')
context = {
'template': template,
}
return render(request, 'surveys/template_confirm_delete.html', context)
@login_required
@require_http_methods(["POST"])
def survey_log_patient_contact(request, pk):
"""
Log patient contact for negative survey.
This records that the user contacted the patient to discuss
the negative survey feedback.
"""
survey = get_object_or_404(SurveyInstance, pk=pk)
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
if user.hospital and survey.survey_template.hospital != user.hospital:
messages.error(request, "You don't have permission to modify this survey.")
return redirect('surveys:instance_detail', pk=pk)
# Check if survey is negative
if not survey.is_negative:
messages.warning(request, "This survey is not marked as negative.")
return redirect('surveys:instance_detail', pk=pk)
# Get form data
contact_notes = request.POST.get('contact_notes', '')
issue_resolved = request.POST.get('issue_resolved') == 'on'
if not contact_notes:
messages.error(request, "Please provide contact notes.")
return redirect('surveys:instance_detail', pk=pk)
try:
# Update survey
survey.patient_contacted = True
survey.patient_contacted_at = timezone.now()
survey.patient_contacted_by = user
survey.contact_notes = contact_notes
survey.issue_resolved = issue_resolved
survey.save(update_fields=[
'patient_contacted', 'patient_contacted_at',
'patient_contacted_by', 'contact_notes', 'issue_resolved'
])
# Log audit
AuditService.log_event(
event_type='survey_patient_contacted',
description=f"Patient contacted for negative survey by {user.get_full_name()}",
user=user,
content_object=survey,
metadata={
'contact_notes': contact_notes,
'issue_resolved': issue_resolved,
'survey_score': float(survey.total_score) if survey.total_score else None
}
)
status = "resolved" if issue_resolved else "discussed"
messages.success(request, f"Patient contact logged successfully. Issue marked as {status}.")
except Exception as e:
messages.error(request, f"Error logging patient contact: {str(e)}")
return redirect('surveys:instance_detail', pk=pk)
@login_required
@require_http_methods(["POST"])
def survey_send_satisfaction_feedback(request, pk):
"""
Send satisfaction feedback form to patient.
This creates and sends a feedback form to assess patient satisfaction
with how their negative survey concerns were addressed.
"""
survey = get_object_or_404(SurveyInstance, pk=pk)
# Check permission
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
if user.hospital and survey.survey_template.hospital != user.hospital:
messages.error(request, "You don't have permission to modify this survey.")
return redirect('surveys:instance_detail', pk=pk)
# Check if survey is negative
if not survey.is_negative:
messages.warning(request, "This survey is not marked as negative.")
return redirect('surveys:instance_detail', pk=pk)
# Check if patient was contacted
if not survey.patient_contacted:
messages.error(request, "Please log patient contact before sending satisfaction feedback.")
return redirect('surveys:instance_detail', pk=pk)
# Check if already sent
if survey.satisfaction_feedback_sent:
messages.warning(request, "Satisfaction feedback has already been sent for this survey.")
return redirect('surveys:instance_detail', pk=pk)
try:
# Trigger async task to send satisfaction feedback
send_satisfaction_feedback.delay(str(survey.id), str(user.id))
messages.success(
request,
"Satisfaction feedback form is being sent to the patient. "
"They will receive a link to provide their feedback."
)
except Exception as e:
messages.error(request, f"Error sending satisfaction feedback: {str(e)}")
return redirect('surveys:instance_detail', pk=pk)