""" 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) # 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]}"