HH/apps/surveys/models.py

425 lines
13 KiB
Python

"""
Surveys models - Survey templates and instances
This module implements the survey system that:
- Defines bilingual survey templates with questions
- Creates survey instances linked to journey stages
- Generates secure token-based survey links
- Collects and scores survey responses
- Triggers actions based on negative feedback
"""
import secrets
from django.core.signing import Signer
from django.db import models
from django.urls import reverse
from apps.core.models import BaseChoices, StatusChoices, TenantModel, TimeStampedModel, UUIDModel
class QuestionType(BaseChoices):
"""Survey question type choices"""
RATING = 'rating', 'Rating (1-5 stars)'
NPS = 'nps', 'NPS (0-10)'
YES_NO = 'yes_no', 'Yes/No'
MULTIPLE_CHOICE = 'multiple_choice', 'Multiple Choice'
TEXT = 'text', 'Text (Short Answer)'
TEXTAREA = 'textarea', 'Text Area (Long Answer)'
LIKERT = 'likert', 'Likert Scale (1-5)'
class SurveyTemplate(UUIDModel, TimeStampedModel):
"""
Survey template defines questions for a survey.
Supports:
- Bilingual questions (AR/EN)
- Multiple question types
- Scoring configuration
- Branch logic (conditional questions)
"""
name = models.CharField(max_length=200)
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
description = models.TextField(blank=True)
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
# Configuration
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='survey_templates'
)
# Survey type
survey_type = models.CharField(
max_length=50,
choices=[
('stage', 'Journey Stage Survey'),
('complaint_resolution', 'Complaint Resolution Satisfaction'),
('general', 'General Feedback'),
('nps', 'Net Promoter Score'),
],
default='stage',
db_index=True
)
# Scoring configuration
scoring_method = models.CharField(
max_length=20,
choices=[
('average', 'Average Score'),
('weighted', 'Weighted Average'),
('nps', 'NPS Calculation'),
],
default='average'
)
negative_threshold = models.DecimalField(
max_digits=3,
decimal_places=1,
default=3.0,
help_text="Scores below this trigger PX actions (out of 5)"
)
# Configuration
is_active = models.BooleanField(default=True, db_index=True)
# Metadata
version = models.IntegerField(default=1)
class Meta:
ordering = ['hospital', 'name']
indexes = [
models.Index(fields=['hospital', 'survey_type', 'is_active']),
]
def __str__(self):
return self.name
def get_question_count(self):
"""Get number of questions"""
return self.questions.count()
class SurveyQuestion(UUIDModel, TimeStampedModel):
"""
Survey question within a template.
Supports:
- Bilingual text (AR/EN)
- Multiple question types
- Required/optional
- Conditional display (branch logic)
"""
survey_template = models.ForeignKey(
SurveyTemplate,
on_delete=models.CASCADE,
related_name='questions'
)
# Question text
text = models.TextField(verbose_name="Question Text (English)")
text_ar = models.TextField(blank=True, verbose_name="Question Text (Arabic)")
# Question configuration
question_type = models.CharField(
max_length=20,
choices=QuestionType.choices,
default=QuestionType.RATING
)
order = models.IntegerField(default=0, help_text="Display order")
is_required = models.BooleanField(default=True)
# For multiple choice questions
choices_json = models.JSONField(
default=list,
blank=True,
help_text="Array of choice objects: [{'value': '1', 'label': 'Option 1', 'label_ar': 'خيار 1'}]"
)
# Scoring
weight = models.DecimalField(
max_digits=3,
decimal_places=2,
default=1.0,
help_text="Weight for weighted average scoring"
)
# Branch logic
branch_logic = models.JSONField(
default=dict,
blank=True,
help_text="Conditional display logic: {'show_if': {'question_id': 'value'}}"
)
# Help text
help_text = models.TextField(blank=True)
help_text_ar = models.TextField(blank=True)
class Meta:
ordering = ['survey_template', 'order']
indexes = [
models.Index(fields=['survey_template', 'order']),
]
def __str__(self):
return f"{self.survey_template.name} - Q{self.order}: {self.text[:50]}"
class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
"""
Survey instance - an actual survey sent to a patient.
Linked to:
- Survey template (defines questions)
- Patient (recipient)
- Journey stage (optional - if stage survey)
- Encounter (optional)
Tenant-aware: All surveys are scoped to a hospital.
"""
survey_template = models.ForeignKey(
SurveyTemplate,
on_delete=models.PROTECT,
related_name='instances'
)
# Patient information
patient = models.ForeignKey(
'organizations.Patient',
on_delete=models.CASCADE,
related_name='surveys'
)
# Journey linkage (for stage surveys)
journey_instance = models.ForeignKey(
'journeys.PatientJourneyInstance',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='surveys'
)
journey_stage_instance = models.ForeignKey(
'journeys.PatientJourneyStageInstance',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='surveys'
)
encounter_id = models.CharField(max_length=100, blank=True, db_index=True)
# Delivery
delivery_channel = models.CharField(
max_length=20,
choices=[
('sms', 'SMS'),
('whatsapp', 'WhatsApp'),
('email', 'Email'),
],
default='sms'
)
recipient_phone = models.CharField(max_length=20, blank=True)
recipient_email = models.EmailField(blank=True)
# Access token for secure link
access_token = models.CharField(
max_length=100,
unique=True,
db_index=True,
blank=True,
help_text="Secure token for survey access"
)
token_expires_at = models.DateTimeField(
null=True,
blank=True,
help_text="Token expiration date"
)
# Status
status = models.CharField(
max_length=20,
choices=StatusChoices.choices,
default=StatusChoices.PENDING,
db_index=True
)
# Timestamps
sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
opened_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
# Scoring
total_score = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Calculated total score"
)
is_negative = models.BooleanField(
default=False,
db_index=True,
help_text="True if score below threshold"
)
# Metadata
metadata = models.JSONField(default=dict, blank=True)
# Patient contact tracking (for negative surveys)
patient_contacted = models.BooleanField(
default=False,
help_text="Whether patient was contacted about negative survey"
)
patient_contacted_at = models.DateTimeField(null=True, blank=True)
patient_contacted_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='contacted_surveys',
help_text="User who contacted the patient"
)
contact_notes = models.TextField(
blank=True,
help_text="Notes from patient contact"
)
issue_resolved = models.BooleanField(
default=False,
help_text="Whether the issue was resolved/explained"
)
# Satisfaction feedback tracking
satisfaction_feedback_sent = models.BooleanField(
default=False,
help_text="Whether satisfaction feedback form was sent"
)
satisfaction_feedback_sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['patient', '-created_at']),
models.Index(fields=['status', '-sent_at']),
models.Index(fields=['is_negative', '-completed_at']),
]
def __str__(self):
return f"{self.survey_template.name} - {self.patient.get_full_name()}"
def save(self, *args, **kwargs):
"""Generate access token on creation"""
if not self.access_token:
self.access_token = secrets.token_urlsafe(32)
# Set token expiration
if not self.token_expires_at:
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
days = getattr(settings, 'SURVEY_TOKEN_EXPIRY_DAYS', 30)
self.token_expires_at = timezone.now() + timedelta(days=days)
super().save(*args, **kwargs)
def get_survey_url(self):
"""Generate secure survey URL"""
# TODO: Implement in Phase 4 UI
return f"/surveys/{self.access_token}/"
def calculate_score(self):
"""
Calculate total score from responses.
Returns the calculated score and updates the instance.
"""
responses = self.responses.all()
if not responses.exists():
return None
if self.survey_template.scoring_method == 'average':
# Simple average of all rating responses
rating_responses = responses.filter(
question__question_type__in=['rating', 'likert', 'nps']
)
if rating_responses.exists():
total = sum(float(r.numeric_value or 0) for r in rating_responses)
count = rating_responses.count()
score = total / count if count > 0 else 0
else:
score = 0
elif self.survey_template.scoring_method == 'weighted':
# Weighted average based on question weights
total_weighted = 0
total_weight = 0
for response in responses:
if response.numeric_value and response.question.weight:
total_weighted += float(response.numeric_value) * float(response.question.weight)
total_weight += float(response.question.weight)
score = total_weighted / total_weight if total_weight > 0 else 0
else: # NPS
# NPS calculation: % promoters - % detractors
nps_responses = responses.filter(question__question_type='nps')
if nps_responses.exists():
promoters = nps_responses.filter(numeric_value__gte=9).count()
detractors = nps_responses.filter(numeric_value__lte=6).count()
total = nps_responses.count()
score = ((promoters - detractors) / total * 100) if total > 0 else 0
else:
score = 0
# Update instance
self.total_score = score
self.is_negative = score < float(self.survey_template.negative_threshold)
self.save(update_fields=['total_score', 'is_negative'])
return score
class SurveyResponse(UUIDModel, TimeStampedModel):
"""
Survey response - answer to a specific question.
"""
survey_instance = models.ForeignKey(
SurveyInstance,
on_delete=models.CASCADE,
related_name='responses'
)
question = models.ForeignKey(
SurveyQuestion,
on_delete=models.PROTECT,
related_name='responses'
)
# Response value (type depends on question type)
numeric_value = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
help_text="For rating, NPS, Likert questions"
)
text_value = models.TextField(
blank=True,
help_text="For text, textarea questions"
)
choice_value = models.CharField(
max_length=200,
blank=True,
help_text="For multiple choice questions"
)
# Metadata
response_time_seconds = models.IntegerField(
null=True,
blank=True,
help_text="Time taken to answer this question"
)
class Meta:
ordering = ['survey_instance', 'question__order']
unique_together = [['survey_instance', 'question']]
def __str__(self):
return f"{self.survey_instance} - {self.question.text[:30]}"