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