HH/apps/surveys/models.py
2026-01-24 15:27:30 +03:00

503 lines
15 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 SurveyStatus(BaseChoices):
"""Survey status choices with enhanced tracking"""
SENT = 'sent', 'Sent (Not Opened)'
VIEWED = 'viewed', 'Viewed (Opened, Not Started)'
IN_PROGRESS = 'in_progress', 'In Progress (Started, Not Completed)'
COMPLETED = 'completed', 'Completed'
ABANDONED = 'abandoned', 'Abandoned (Started but Left)'
EXPIRED = 'expired', 'Expired'
CANCELLED = 'cancelled', 'Cancelled'
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)")
# 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)
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'}]"
)
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
journey_instance = models.ForeignKey(
'journeys.PatientJourneyInstance',
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=SurveyStatus.choices,
default=SurveyStatus.SENT,
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)
# Enhanced tracking
open_count = models.IntegerField(
default=0,
help_text="Number of times survey link was opened"
)
last_opened_at = models.DateTimeField(
null=True,
blank=True,
help_text="Most recent time survey was opened"
)
time_spent_seconds = models.IntegerField(
null=True,
blank=True,
help_text="Total time spent on survey in seconds"
)
# 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"
)
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"""
return f"/surveys/s/{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':
# Simple average (weight feature removed)
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
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"
)
class Meta:
ordering = ['survey_instance', 'question__order']
unique_together = [['survey_instance', 'question']]
class SurveyTracking(UUIDModel, TimeStampedModel):
"""
Detailed survey engagement tracking.
Tracks multiple interactions with survey:
- Page views
- Time spent on survey
- Abandonment events
- Device/browser information
"""
survey_instance = models.ForeignKey(
SurveyInstance,
on_delete=models.CASCADE,
related_name='tracking_events'
)
# Event type
event_type = models.CharField(
max_length=50,
choices=[
('page_view', 'Page View'),
('survey_started', 'Survey Started'),
('question_answered', 'Question Answered'),
('survey_abandoned', 'Survey Abandoned'),
('survey_completed', 'Survey Completed'),
('reminder_sent', 'Reminder Sent'),
],
db_index=True
)
# Timing
time_on_page = models.IntegerField(
null=True,
blank=True,
help_text="Time spent on page in seconds"
)
total_time_spent = models.IntegerField(
null=True,
blank=True,
help_text="Total time spent on survey so far in seconds"
)
# Context
current_question = models.IntegerField(
null=True,
blank=True,
help_text="Question number when event occurred"
)
# Device info
user_agent = models.TextField(blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
device_type = models.CharField(
max_length=50,
blank=True,
help_text="mobile, tablet, desktop"
)
browser = models.CharField(
max_length=100,
blank=True
)
# Location (optional, for analytics)
country = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True)
# Metadata
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['survey_instance', 'created_at']
indexes = [
models.Index(fields=['survey_instance', 'event_type', '-created_at']),
models.Index(fields=['event_type', '-created_at']),
]
def __str__(self):
return f"{self.survey_instance.id} - {self.event_type} at {self.created_at}"
@classmethod
def track_event(cls, survey_instance, event_type, **kwargs):
"""
Helper method to track a survey event.
Args:
survey_instance: SurveyInstance
event_type: str - event type key
**kwargs: additional fields (time_on_page, current_question, etc.)
Returns:
SurveyTracking instance
"""
return cls.objects.create(
survey_instance=survey_instance,
event_type=event_type,
**kwargs
)