HH/apps/surveys/ui_views.py
2026-02-22 08:35:53 +03:00

1679 lines
63 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.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
@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)
context = {
'surveys': page_obj,
'hospitals': hospitals,
'filters': request.GET,
}
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
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)
@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)
@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)
@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)
@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)
@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)
@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)
@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
)
@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
)
@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
# ============================================================================
@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)
@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')
@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)
@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)