HH/apps/surveys/models.py
2025-12-24 12:42:31 +03:00

393 lines
12 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, 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):
"""
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)
"""
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)
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]}"