HH/apps/surveys/ui_views.py
2026-03-09 16:10:24 +03:00

1707 lines
64 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.http import FileResponse, HttpResponse
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.core.decorators import block_source_user
from apps.organizations.models import Department, Hospital
from .forms import ManualSurveySendForm, SurveyQuestionFormSet, SurveyTemplateForm, ManualPhoneSurveySendForm, BulkCSVSurveySendForm
from .services import SurveyDeliveryService
from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
from .tasks import send_satisfaction_feedback
from datetime import datetime
@block_source_user
@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
"""
# Source Users don't have access to surveys
if request.user.is_source_user():
from django.core.exceptions import PermissionDenied
raise PermissionDenied("Source users do not have access to surveys.")
# 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)
context = {
'surveys': page_obj,
'hospitals': hospitals,
'filters': request.GET,
}
return render(request, 'surveys/instance_list.html', context)
@block_source_user
@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)
@block_source_user
@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)
@block_source_user
@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, user=user)
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(user=user)
formset = SurveyQuestionFormSet()
context = {
'form': form,
'formset': formset,
}
return render(request, 'surveys/template_form.html', context)
@block_source_user
@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)
@block_source_user
@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, user=user)
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, user=user)
formset = SurveyQuestionFormSet(instance=template)
context = {
'form': form,
'formset': formset,
'template': template,
}
return render(request, 'surveys/template_form.html', context)
@block_source_user
@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)
@block_source_user
@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)
@block_source_user
@login_required
def survey_comments_list(request):
"""
Survey comments list view with AI analysis.
Features:
- Display all survey comments with AI analysis
- Filters (sentiment, survey type, hospital, date range)
- Search by patient MRN
- Server-side pagination
- PatientType display from journey
"""
# Base queryset - only completed surveys with comments
queryset = SurveyInstance.objects.select_related(
'survey_template',
'patient',
'journey_instance__journey_template'
).filter(
status='completed',
comment__isnull=False
).exclude(
comment=''
)
# 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
sentiment_filter = request.GET.get('sentiment')
if sentiment_filter:
queryset = queryset.filter(
comment_analysis__sentiment=sentiment_filter
)
survey_type = request.GET.get('survey_type')
if survey_type:
queryset = queryset.filter(survey_template__survey_type=survey_type)
hospital_filter = request.GET.get('hospital')
if hospital_filter:
queryset = queryset.filter(survey_template__hospital_id=hospital_filter)
# Patient type filter
patient_type_filter = request.GET.get('patient_type')
if patient_type_filter:
# Map filter values to HIS codes
patient_type_map = {
'outpatient': ['1'],
'inpatient': ['2', 'O'],
'emergency': ['3', 'E'],
}
if patient_type_filter in patient_type_map:
codes = patient_type_map[patient_type_filter]
queryset = queryset.filter(
metadata__patient_type__in=codes
)
# 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(comment__icontains=search_query)
)
# Date range
date_from = request.GET.get('date_from')
if date_from:
queryset = queryset.filter(completed_at__gte=date_from)
date_to = request.GET.get('date_to')
if date_to:
queryset = queryset.filter(completed_at__lte=date_to)
# Ordering
order_by = request.GET.get('order_by', '-completed_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)
# Statistics
stats_queryset = SurveyInstance.objects.filter(
status='completed',
comment__isnull=False
).exclude(
comment=''
)
# Apply same RBAC filters to stats
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()
total_count = stats_queryset.count()
positive_count = stats_queryset.filter(
comment_analysis__sentiment='positive'
).count()
negative_count = stats_queryset.filter(
comment_analysis__sentiment='negative'
).count()
neutral_count = stats_queryset.filter(
comment_analysis__sentiment='neutral'
).count()
analyzed_count = stats_queryset.filter(
comment_analyzed=True
).count()
# Patient Type Distribution Chart Data
patient_type_distribution = []
patient_type_labels = []
patient_type_mapping = {
'1': 'Outpatient',
'2': 'Inpatient',
'O': 'Inpatient',
'3': 'Emergency',
'E': 'Emergency',
}
# Count each patient type
outpatient_count = stats_queryset.filter(
metadata__patient_type__in=['1']
).count()
inpatient_count = stats_queryset.filter(
metadata__patient_type__in=['2', 'O']
).count()
emergency_count = stats_queryset.filter(
metadata__patient_type__in=['3', 'E']
).count()
unknown_count = total_count - (outpatient_count + inpatient_count + emergency_count)
patient_type_distribution = [
{'type': 'outpatient', 'label': 'Outpatient', 'count': outpatient_count, 'percentage': round((outpatient_count / total_count * 100) if total_count > 0 else 0, 1)},
{'type': 'inpatient', 'label': 'Inpatient', 'count': inpatient_count, 'percentage': round((inpatient_count / total_count * 100) if total_count > 0 else 0, 1)},
{'type': 'emergency', 'label': 'Emergency', 'count': emergency_count, 'percentage': round((emergency_count / total_count * 100) if total_count > 0 else 0, 1)},
{'type': 'unknown', 'label': 'N/A', 'count': unknown_count, 'percentage': round((unknown_count / total_count * 100) if total_count > 0 else 0, 1)},
]
# Sentiment by Patient Type Chart Data
sentiment_by_patient_type = {
'types': ['Outpatient', 'Inpatient', 'Emergency', 'N/A'],
'positive': [],
'negative': [],
'neutral': [],
}
# Calculate sentiment for each patient type
for pt_type, codes in [('outpatient', ['1']), ('inpatient', ['2', 'O']), ('emergency', ['3', 'E']), ('unknown', [])]:
if codes:
pt_queryset = stats_queryset.filter(metadata__patient_type__in=codes)
else:
pt_queryset = stats_queryset.exclude(
metadata__patient_type__in=['1', '2', 'O', '3', 'E']
)
pt_positive = pt_queryset.filter(comment_analysis__sentiment='positive').count()
pt_negative = pt_queryset.filter(comment_analysis__sentiment='negative').count()
pt_neutral = pt_queryset.filter(comment_analysis__sentiment='neutral').count()
sentiment_by_patient_type['positive'].append(pt_positive)
sentiment_by_patient_type['negative'].append(pt_negative)
sentiment_by_patient_type['neutral'].append(pt_neutral)
# PatientType mapping helper - maps HIS codes to display values
# HIS codes: "1"=Inpatient, "2"/"O"=Outpatient, "3"/"E"=Emergency
PATIENT_TYPE_MAPPING = {
'1': {'label': 'Outpatient', 'icon': 'bi-person-walking', 'color': 'bg-primary'},
'2': {'label': 'Inpatient', 'icon': 'bi-hospital', 'color': 'bg-warning'},
'O': {'label': 'Inpatient', 'icon': 'bi-hospital', 'color': 'bg-warning'},
'3': {'label': 'Emergency', 'icon': 'bi-ambulance', 'color': 'bg-danger'},
'E': {'label': 'Emergency', 'icon': 'bi-ambulance', 'color': 'bg-danger'},
}
def get_patient_type_display(survey):
"""Get patient type display for a survey from HIS metadata"""
# Get patient_type from survey metadata (saved by HIS adapter)
patient_type_code = survey.metadata.get('patient_type')
if patient_type_code:
type_info = PATIENT_TYPE_MAPPING.get(str(patient_type_code))
if type_info:
return {
'code': patient_type_code,
'label': type_info['label'],
'icon': type_info['icon'],
'color': type_info['color']
}
# Fallback: try to get from journey if available
if survey.journey_instance and survey.journey_instance.journey_template:
journey_type = survey.journey_instance.journey_template.journey_type
journey_mapping = {
'opd': {'label': 'Outpatient', 'icon': 'bi-person-walking', 'color': 'bg-primary'},
'inpatient': {'label': 'Inpatient', 'icon': 'bi-hospital', 'color': 'bg-warning'},
'ems': {'label': 'Emergency', 'icon': 'bi-ambulance', 'color': 'bg-danger'},
'day_case': {'label': 'Day Case', 'icon': 'bi-calendar-day', 'color': 'bg-info'},
}
return journey_mapping.get(journey_type, {'code': None, 'label': 'N/A', 'icon': 'bi-question-circle', 'color': 'bg-secondary'})
return {'code': None, 'label': 'N/A', 'icon': 'bi-question-circle', 'color': 'bg-secondary'}
# Add patient type info to surveys
for survey in page_obj.object_list:
survey.patient_type = get_patient_type_display(survey)
stats = {
'total': total_count,
'positive': positive_count,
'negative': negative_count,
'neutral': neutral_count,
'analyzed': analyzed_count,
'unanalyzed': total_count - analyzed_count,
}
# Serialize chart data to JSON
import json
patient_type_distribution_json = json.dumps(patient_type_distribution)
sentiment_by_patient_type_json = json.dumps(sentiment_by_patient_type)
context = {
'page_obj': page_obj,
'surveys': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'filters': request.GET,
# Chart data
'patient_type_distribution': patient_type_distribution,
'sentiment_by_patient_type': sentiment_by_patient_type,
'patient_type_distribution_json': patient_type_distribution_json,
'sentiment_by_patient_type_json': sentiment_by_patient_type_json,
}
return render(request, 'surveys/comment_list.html', context)
@block_source_user
@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)
@block_source_user
@login_required
def manual_survey_send(request):
"""
Manually send a survey to a patient or staff member.
Features:
- Select survey template
- Choose recipient type (patient/staff)
- Search and select recipient
- Choose delivery channel (email/SMS)
- Add custom message (optional)
- Send survey immediately
"""
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to send surveys manually.")
return redirect('surveys:instance_list')
if request.method == 'POST':
form = ManualSurveySendForm(user, request.POST)
if form.is_valid():
try:
# Get form data
survey_template = form.cleaned_data['survey_template']
recipient_type = form.cleaned_data['recipient_type']
recipient_id = form.cleaned_data['recipient']
delivery_channel = form.cleaned_data['delivery_channel']
custom_message = form.cleaned_data.get('custom_message', '')
# Get recipient object
from apps.organizations.models import Patient, Staff
if recipient_type == 'patient':
recipient = get_object_or_404(Patient, pk=recipient_id)
recipient_name = f"{recipient.first_name} {recipient.last_name}"
else:
recipient = get_object_or_404(Staff, pk=recipient_id)
recipient_name = f"{recipient.first_name} {recipient.last_name}"
# Check if recipient has contact info for selected channel
if delivery_channel == 'email':
contact_info = recipient.email
if not contact_info:
messages.error(request, f"{recipient_type.title()} does not have an email address.")
return render(request, 'surveys/manual_send.html', {'form': form})
elif delivery_channel == 'sms':
contact_info = recipient.phone
if not contact_info:
messages.error(request, f"{recipient_type.title()} does not have a phone number.")
return render(request, 'surveys/manual_send.html', {'form': form})
# Create survey instance
from .models import SurveyStatus
survey_instance = SurveyInstance.objects.create(
survey_template=survey_template,
patient=recipient if recipient_type == 'patient' else None,
staff=recipient if recipient_type == 'staff' else None,
hospital=survey_template.hospital,
delivery_channel=delivery_channel,
recipient_email=contact_info if delivery_channel == 'email' else None,
recipient_phone=contact_info if delivery_channel == 'sms' else None,
status=SurveyStatus.SENT,
metadata={
'sent_manually': True,
'sent_by': str(user.id),
'custom_message': custom_message,
'recipient_type': recipient_type
}
)
# Send survey
success = SurveyDeliveryService.deliver_survey(survey_instance)
if success:
# Log audit
AuditService.log_event(
event_type='survey_sent_manually',
description=f"Survey sent manually to {recipient_name} ({recipient_type}) by {user.get_full_name()}",
user=user,
content_object=survey_instance,
metadata={
'survey_template': survey_template.name,
'recipient_type': recipient_type,
'recipient_id': recipient_id,
'delivery_channel': delivery_channel,
'custom_message': custom_message
}
)
messages.success(
request,
f"Survey sent successfully to {recipient_name} via {delivery_channel.upper()}. "
f"Survey ID: {survey_instance.id}"
)
return redirect('surveys:instance_detail', pk=survey_instance.pk)
else:
messages.error(request, "Failed to send survey. Please try again.")
survey_instance.delete()
return render(request, 'surveys/manual_send.html', {'form': form})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error sending survey manually: {str(e)}", exc_info=True)
messages.error(request, f"Error sending survey: {str(e)}")
return render(request, 'surveys/manual_send.html', {'form': form})
else:
form = ManualSurveySendForm(user)
context = {
'form': form,
}
return render(request, 'surveys/manual_send.html', context)
@block_source_user
@login_required
def manual_survey_send_phone(request):
"""
Send survey to a manually entered phone number.
Features:
- Enter phone number directly
- Optional recipient name
- Send via SMS only
"""
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to send surveys manually.")
return redirect('surveys:instance_list')
if request.method == 'POST':
form = ManualPhoneSurveySendForm(user, request.POST)
if form.is_valid():
try:
survey_template = form.cleaned_data['survey_template']
phone_number = form.cleaned_data['phone_number']
recipient_name = form.cleaned_data.get('recipient_name', '')
custom_message = form.cleaned_data.get('custom_message', '')
# Create survey instance
from .models import SurveyStatus
survey_instance = SurveyInstance.objects.create(
survey_template=survey_template,
hospital=survey_template.hospital,
delivery_channel='sms',
recipient_phone=phone_number,
status=SurveyStatus.SENT,
metadata={
'sent_manually': True,
'sent_by': str(user.id),
'custom_message': custom_message,
'recipient_name': recipient_name,
'recipient_type': 'manual_phone'
}
)
# Send survey
success = SurveyDeliveryService.deliver_survey(survey_instance)
if success:
# Log audit
AuditService.log_event(
event_type='survey_sent_manually_phone',
description=f"Survey sent manually to phone {phone_number} by {user.get_full_name()}",
user=user,
content_object=survey_instance,
metadata={
'survey_template': survey_template.name,
'phone_number': phone_number,
'recipient_name': recipient_name,
'custom_message': custom_message
}
)
display_name = recipient_name if recipient_name else phone_number
messages.success(
request,
f"Survey sent successfully to {display_name} via SMS. Survey ID: {survey_instance.id}"
)
return redirect('surveys:instance_detail', pk=survey_instance.pk)
else:
messages.error(request, "Failed to send survey. Please try again.")
survey_instance.delete()
return render(request, 'surveys/manual_send_phone.html', {'form': form})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error sending survey to phone: {str(e)}", exc_info=True)
messages.error(request, f"Error sending survey: {str(e)}")
return render(request, 'surveys/manual_send_phone.html', {'form': form})
else:
form = ManualPhoneSurveySendForm(user)
context = {
'form': form,
}
return render(request, 'surveys/manual_send_phone.html', context)
@block_source_user
@login_required
def manual_survey_send_csv(request):
"""
Bulk send surveys via CSV upload.
CSV Format:
phone_number,name(optional)
+966501234567,John Doe
+966501234568,Jane Smith
Features:
- Upload CSV with phone numbers
- Optional names for each recipient
- Bulk processing with success/failure reporting
"""
import csv
import io
from django.utils.translation import gettext as _
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to send surveys manually.")
return redirect('surveys:instance_list')
if request.method == 'POST':
form = BulkCSVSurveySendForm(user, request.POST, request.FILES)
if form.is_valid():
try:
survey_template = form.cleaned_data['survey_template']
csv_file = form.cleaned_data['csv_file']
custom_message = form.cleaned_data.get('custom_message', '')
# Parse CSV
decoded_file = csv_file.read().decode('utf-8-sig') # Handle BOM
io_string = io.StringIO(decoded_file)
reader = csv.reader(io_string)
# Skip header if present
first_row = next(reader, None)
if not first_row:
messages.error(request, "CSV file is empty.")
return render(request, 'surveys/manual_send_csv.html', {'form': form})
# Check if first row is header or data
rows = []
if first_row[0].strip().lower() in ['phone', 'phone_number', 'mobile', 'number', 'tel']:
# First row is header, use remaining rows
pass
else:
# First row is data
rows.append(first_row)
# Read remaining rows
for row in reader:
if row and row[0].strip(): # Skip empty rows
rows.append(row)
if not rows:
messages.error(request, "No valid phone numbers found in CSV.")
return render(request, 'surveys/manual_send_csv.html', {'form': form})
# Process each row
from .models import SurveyStatus
success_count = 0
failed_count = 0
failed_numbers = []
created_instances = []
import logging
logger = logging.getLogger(__name__)
for row in rows:
try:
phone_number = row[0].strip() if len(row) > 0 else ''
recipient_name = row[1].strip() if len(row) > 1 else ''
# Clean phone number
phone_number = phone_number.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')
logger.info(f"Processing row: phone={phone_number}, name={recipient_name}")
# Skip empty or invalid
if not phone_number or not phone_number.startswith('+'):
failed_count += 1
failed_numbers.append(f"{phone_number} (invalid format - must start with +)")
logger.warning(f"Invalid phone format: {phone_number}")
continue
# Create survey instance
survey_instance = SurveyInstance.objects.create(
survey_template=survey_template,
hospital=survey_template.hospital,
delivery_channel='sms',
recipient_phone=phone_number,
status=SurveyStatus.SENT,
metadata={
'sent_manually': True,
'sent_by': str(user.id),
'custom_message': custom_message,
'recipient_name': recipient_name,
'recipient_type': 'csv_upload',
'csv_row': row
}
)
logger.info(f"Created survey instance: {survey_instance.id}")
# Send survey
success = SurveyDeliveryService.deliver_survey(survey_instance)
logger.info(f"Survey delivery result for {phone_number}: success={success}")
if success:
success_count += 1
created_instances.append(survey_instance)
else:
failed_count += 1
failed_numbers.append(f"{phone_number} (delivery failed - check logs)")
survey_instance.delete()
except Exception as e:
failed_count += 1
phone_display = phone_number if 'phone_number' in locals() else 'unknown'
failed_numbers.append(f"{phone_display} (exception: {str(e)})")
logger.error(f"Exception processing row {row}: {str(e)}", exc_info=True)
# Log audit for bulk operation
AuditService.log_event(
event_type='survey_sent_manually_csv',
description=f"Bulk survey send via CSV: {success_count} successful, {failed_count} failed by {user.get_full_name()}",
user=user,
metadata={
'survey_template': survey_template.name,
'success_count': success_count,
'failed_count': failed_count,
'total_count': len(rows),
'custom_message': custom_message
}
)
# Show results
if success_count > 0 and failed_count == 0:
messages.success(
request,
f"Successfully sent {success_count} surveys from CSV."
)
elif success_count > 0 and failed_count > 0:
messages.warning(
request,
f"Sent {success_count} surveys successfully. {failed_count} failed. Check failed numbers: {', '.join(failed_numbers[:5])}{'...' if len(failed_numbers) > 5 else ''}"
)
else:
messages.error(
request,
f"All {failed_count} surveys failed to send. Check the phone numbers and try again."
)
# Redirect to list view with filter for these surveys
if created_instances:
return redirect('surveys:instance_list')
else:
return render(request, 'surveys/manual_send_csv.html', {'form': form})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error processing CSV upload: {str(e)}", exc_info=True)
messages.error(request, f"Error processing CSV: {str(e)}")
return render(request, 'surveys/manual_send_csv.html', {'form': form})
else:
form = BulkCSVSurveySendForm(user)
context = {
'form': form,
}
return render(request, 'surveys/manual_send_csv.html', context)
@block_source_user
@login_required
def survey_analytics_reports(request):
"""
Survey analytics reports management page.
Features:
- List all available reports
- View report details
- Generate new reports
- Download reports
- Delete reports
"""
import os
from django.conf import settings
user = request.user
# Check permission - only admins and hospital admins can view reports
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to view analytics reports.")
return redirect('surveys:instance_list')
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
# Get all reports
reports = []
if os.path.exists(output_dir):
for filename in os.listdir(output_dir):
filepath = os.path.join(output_dir, filename)
if os.path.isfile(filepath):
stat = os.stat(filepath)
report_type = 'unknown'
if filename.endswith('.json'):
report_type = 'json'
elif filename.endswith('.html'):
report_type = 'html'
elif filename.endswith('.md'):
report_type = 'markdown'
reports.append({
'filename': filename,
'type': report_type,
'size': stat.st_size,
'size_human': _human_readable_size(stat.st_size),
'created': stat.st_ctime,
'created_date': datetime.fromtimestamp(stat.st_ctime),
'modified': stat.st_mtime,
})
# Sort by creation date (newest first)
reports.sort(key=lambda x: x['created'], reverse=True)
# Statistics
total_reports = len(reports)
json_reports = len([r for r in reports if r['type'] == 'json'])
html_reports = len([r for r in reports if r['type'] == 'html'])
markdown_reports = len([r for r in reports if r['type'] == 'markdown'])
context = {
'reports': reports,
'stats': {
'total': total_reports,
'json': json_reports,
'html': html_reports,
'markdown': markdown_reports,
},
}
return render(request, 'surveys/analytics_reports.html', context)
@block_source_user
@login_required
def survey_analytics_report_view(request, filename):
"""
View a specific survey analytics report.
Features:
- Display HTML reports in browser
- Provide download links for all formats
- Show report metadata
"""
import os
from django.conf import settings
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to view analytics reports.")
return redirect('surveys:analytics_reports')
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
filepath = os.path.join(output_dir, filename)
# Security check - ensure file is in reports directory
if not os.path.abspath(filepath).startswith(os.path.abspath(output_dir)):
messages.error(request, "Invalid report file.")
return redirect('surveys:analytics_reports')
if not os.path.exists(filepath):
messages.error(request, "Report file not found.")
return redirect('surveys:analytics_reports')
# Determine report type
report_type = 'unknown'
if filename.endswith('.json'):
report_type = 'json'
elif filename.endswith('.html'):
report_type = 'html'
elif filename.endswith('.md'):
report_type = 'markdown'
# Get file info
stat = os.stat(filepath)
# For HTML files, render them directly
if report_type == 'html':
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
context = {
'filename': filename,
'content': content,
'report_type': report_type,
'size': stat.st_size,
'size_human': _human_readable_size(stat.st_size),
'created_date': datetime.fromtimestamp(stat.st_ctime),
'modified_date': datetime.fromtimestamp(stat.st_mtime),
}
return render(request, 'surveys/analytics_report_view.html', context)
# For JSON and Markdown files, show info and provide download
context = {
'filename': filename,
'report_type': report_type,
'size': stat.st_size,
'size_human': _human_readable_size(stat.st_size),
'created_date': datetime.fromtimestamp(stat.st_ctime),
'modified_date': datetime.fromtimestamp(stat.st_mtime),
}
return render(request, 'surveys/analytics_report_info.html', context)
@block_source_user
@login_required
def survey_analytics_report_download(request, filename):
"""
Download a survey analytics report file.
"""
import os
from django.conf import settings
from django.http import FileResponse
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to download analytics reports.")
return redirect('surveys:analytics_reports')
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
filepath = os.path.join(output_dir, filename)
# Security check
if not os.path.abspath(filepath).startswith(os.path.abspath(output_dir)):
messages.error(request, "Invalid report file.")
return redirect('surveys:analytics_reports')
if not os.path.exists(filepath):
messages.error(request, "Report file not found.")
return redirect('surveys:analytics_reports')
# Determine content type
content_type = 'application/octet-stream'
if filename.endswith('.html'):
content_type = 'text/html'
elif filename.endswith('.json'):
content_type = 'application/json'
elif filename.endswith('.md'):
content_type = 'text/markdown'
# Send file
return FileResponse(
open(filepath, 'rb'),
content_type=content_type,
as_attachment=True,
filename=filename
)
@block_source_user
@login_required
def survey_analytics_report_view_inline(request, filename):
"""
View a survey analytics report inline in the browser.
Unlike download, this displays the file content directly in the browser
without forcing a download. Works for HTML, JSON, and Markdown files.
"""
import os
from django.conf import settings
from django.http import FileResponse, HttpResponse
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to view analytics reports.")
return redirect('surveys:analytics_reports')
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
filepath = os.path.join(output_dir, filename)
# Security check
if not os.path.abspath(filepath).startswith(os.path.abspath(output_dir)):
messages.error(request, "Invalid report file.")
return redirect('surveys:analytics_reports')
if not os.path.exists(filepath):
messages.error(request, "Report file not found.")
return redirect('surveys:analytics_reports')
# Determine content type
content_type = 'application/octet-stream'
if filename.endswith('.html'):
content_type = 'text/html'
elif filename.endswith('.json'):
content_type = 'application/json'
elif filename.endswith('.md'):
content_type = 'text/plain; charset=utf-8' # Plain text for markdown
# For HTML and JSON, serve inline with FileResponse
if filename.endswith(('.html', '.json')):
response = FileResponse(
open(filepath, 'rb'),
content_type=content_type,
as_attachment=False # Display inline
)
# Add CSP header to allow inline scripts for HTML reports
if filename.endswith('.html'):
response['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' cdn.jsdelivr.net cdnjs.cloudflare.com;"
return response
# For Markdown, render it in a template with viewer
elif filename.endswith('.md'):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
stat = os.stat(filepath)
context = {
'filename': filename,
'content': content,
'report_type': 'markdown',
'size': stat.st_size,
'size_human': _human_readable_size(stat.st_size),
'created_date': datetime.fromtimestamp(stat.st_ctime),
'modified_date': datetime.fromtimestamp(stat.st_mtime),
}
return render(request, 'surveys/analytics_report_markdown_view.html', context)
# For other files, fallback to download
return FileResponse(
open(filepath, 'rb'),
content_type=content_type,
as_attachment=True,
filename=filename
)
@block_source_user
@login_required
@require_http_methods(["POST"])
def survey_analytics_report_delete(request, filename):
"""
Delete a survey analytics report file.
"""
import os
from django.conf import settings
user = request.user
# Check permission - only PX admins can delete reports
if not user.is_px_admin():
messages.error(request, "You don't have permission to delete analytics reports.")
return redirect('surveys:analytics_reports')
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
filepath = os.path.join(output_dir, filename)
# Security check
if not os.path.abspath(filepath).startswith(os.path.abspath(output_dir)):
messages.error(request, "Invalid report file.")
return redirect('surveys:analytics_reports')
if not os.path.exists(filepath):
messages.error(request, "Report file not found.")
return redirect('surveys:analytics_reports')
try:
os.remove(filepath)
messages.success(request, f"Report '{filename}' deleted successfully.")
except Exception as e:
messages.error(request, f"Error deleting report: {str(e)}")
return redirect('surveys:analytics_reports')
def _human_readable_size(size_bytes):
"""Convert bytes to human readable format"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} TB"
# ============================================================================
# ENHANCED SURVEY REPORTS - Separate reports per survey type
# ============================================================================
@block_source_user
@login_required
def enhanced_survey_reports_list(request):
"""
List all enhanced survey report directories.
"""
from django.conf import settings
import os
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to view analytics reports.")
return redirect('surveys:analytics_reports')
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
# Find all report directories (folders starting with "reports_")
report_sets = []
if os.path.exists(output_dir):
for item in sorted(os.listdir(output_dir), reverse=True):
item_path = os.path.join(output_dir, item)
if os.path.isdir(item_path) and item.startswith('reports_'):
# Check if it has an index.html
index_path = os.path.join(item_path, 'index.html')
if os.path.exists(index_path):
stat = os.stat(item_path)
# Count individual reports
report_count = len([f for f in os.listdir(item_path) if f.endswith('.html') and f != 'index.html'])
report_sets.append({
'dir_name': item,
'created': datetime.fromtimestamp(stat.st_ctime),
'modified': datetime.fromtimestamp(stat.st_mtime),
'report_count': report_count,
'size': sum(os.path.getsize(os.path.join(item_path, f)) for f in os.listdir(item_path) if os.path.isfile(os.path.join(item_path, f)))
})
context = {
'report_sets': report_sets,
}
return render(request, 'surveys/enhanced_reports_list.html', context)
@block_source_user
@login_required
def enhanced_survey_report_view(request, dir_name):
"""
View the index of an enhanced report set.
"""
from django.conf import settings
import os
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to view analytics reports.")
return redirect('surveys:analytics_reports')
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
dir_path = os.path.join(output_dir, dir_name)
# Security check
if not os.path.abspath(dir_path).startswith(os.path.abspath(output_dir)):
messages.error(request, "Invalid report directory.")
return redirect('surveys:enhanced_reports_list')
if not os.path.exists(dir_path):
messages.error(request, "Report directory not found.")
return redirect('surveys:enhanced_reports_list')
# Serve the index.html file
index_path = os.path.join(dir_path, 'index.html')
if os.path.exists(index_path):
with open(index_path, 'r', encoding='utf-8') as f:
content = f.read()
return HttpResponse(content, content_type='text/html')
messages.error(request, "Index file not found.")
return redirect('surveys:enhanced_reports_list')
@block_source_user
@login_required
def enhanced_survey_report_file(request, dir_name, filename):
"""
View/serve an individual enhanced report file.
"""
from django.conf import settings
import os
from django.http import FileResponse
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to view analytics reports.")
return redirect('surveys:analytics_reports')
output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports')
dir_path = os.path.join(output_dir, dir_name)
filepath = os.path.join(dir_path, filename)
# Security check
if not os.path.abspath(filepath).startswith(os.path.abspath(dir_path)):
messages.error(request, "Invalid file path.")
return redirect('surveys:enhanced_reports_list')
if not os.path.exists(filepath):
messages.error(request, "File not found.")
return redirect('surveys:enhanced_reports_list')
# Serve file based on type
if filename.endswith('.html'):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
response = HttpResponse(content, content_type='text/html')
# Allow inline scripts/CDN resources
response['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com unpkg.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net unpkg.com; style-src 'self' 'unsafe-inline' cdn.jsdelivr.net cdnjs.cloudflare.com fonts.googleapis.com; font-src 'self' fonts.gstatic.com;"
return response
elif filename.endswith('.json'):
return FileResponse(open(filepath, 'rb'), content_type='application/json')
else:
return FileResponse(open(filepath, 'rb'), as_attachment=True)
@block_source_user
@login_required
def generate_enhanced_report_ui(request):
"""
UI view to generate enhanced reports with form.
"""
from apps.surveys.models import SurveyTemplate
from django.conf import settings
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to generate analytics reports.")
return redirect('surveys:analytics_reports')
if request.method == 'POST':
template_id = request.POST.get('template')
start_date = request.POST.get('start_date')
end_date = request.POST.get('end_date')
try:
# Get template name if specified
template_name = None
if template_id:
try:
template = SurveyTemplate.objects.get(id=template_id)
template_name = template.name
except SurveyTemplate.DoesNotExist:
pass
# Parse dates
start_dt = None
end_dt = None
if start_date:
start_dt = datetime.strptime(start_date, '%Y-%m-%d').date()
if end_date:
end_dt = datetime.strptime(end_date, '%Y-%m-%d').date()
# Generate enhanced reports
from .analytics_utils import generate_enhanced_survey_reports
result = generate_enhanced_survey_reports(
template_name=template_name,
start_date=start_dt,
end_date=end_dt
)
messages.success(
request,
f"Generated {len(result['individual_reports'])} reports successfully!"
)
# Redirect to the report directory
dir_name = os.path.basename(result['reports_dir'])
return redirect('surveys:enhanced_report_view', dir_name=dir_name)
except Exception as e:
messages.error(request, f"Error generating reports: {str(e)}")
return redirect('surveys:enhanced_reports_list')
# GET request - show form
templates = SurveyTemplate.objects.filter(is_active=True).order_by('name')
context = {
'templates': templates,
}
return render(request, 'surveys/generate_enhanced_report.html', context)