""" 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 )