201 lines
6.4 KiB
Python
201 lines
6.4 KiB
Python
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
from django.utils.text import slugify
|
|
|
|
|
|
class SurveyQuerySet(models.QuerySet):
|
|
def public(self):
|
|
now = timezone.now()
|
|
return (
|
|
self.filter(status=Survey.Status.PUBLISHED)
|
|
.filter(Q(starts_at__isnull=True) | Q(starts_at__lte=now))
|
|
.filter(Q(ends_at__isnull=True) | Q(ends_at__gte=now))
|
|
)
|
|
|
|
|
|
class Survey(models.Model):
|
|
class Status(models.TextChoices):
|
|
DRAFT = "draft", "Draft"
|
|
PUBLISHED = "published", "Published"
|
|
CLOSED = "closed", "Closed"
|
|
|
|
title = models.CharField(max_length=180)
|
|
slug = models.SlugField(
|
|
max_length=140,
|
|
unique=True,
|
|
help_text="Stable public identifier used in API URLs.",
|
|
)
|
|
description = models.TextField(blank=True)
|
|
thank_you_message = models.CharField(
|
|
max_length=240,
|
|
blank=True,
|
|
default="Thanks for your response.",
|
|
)
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=Status.choices,
|
|
default=Status.DRAFT,
|
|
)
|
|
starts_at = models.DateTimeField(blank=True, null=True)
|
|
ends_at = models.DateTimeField(blank=True, null=True)
|
|
allow_multiple_responses = models.BooleanField(default=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
objects = SurveyQuerySet.as_manager()
|
|
|
|
class Meta:
|
|
ordering = ["-updated_at", "title"]
|
|
|
|
def __str__(self) -> str:
|
|
return self.title
|
|
|
|
def clean(self):
|
|
if self.starts_at and self.ends_at and self.starts_at >= self.ends_at:
|
|
raise ValidationError("The survey start time must be before the end time.")
|
|
|
|
def accepts_responses(self):
|
|
now = timezone.now()
|
|
if self.status != self.Status.PUBLISHED:
|
|
return False, "Survey is not published."
|
|
if self.starts_at and self.starts_at > now:
|
|
return False, "Survey has not started yet."
|
|
if self.ends_at and self.ends_at < now:
|
|
return False, "Survey has ended."
|
|
return True, ""
|
|
|
|
|
|
class SurveyQuestion(models.Model):
|
|
class Type(models.TextChoices):
|
|
SHORT_TEXT = "short_text", "Short text"
|
|
LONG_TEXT = "long_text", "Long text"
|
|
SINGLE_CHOICE = "single_choice", "Single choice"
|
|
MULTIPLE_CHOICE = "multiple_choice", "Multiple choice"
|
|
RATING = "rating", "Rating"
|
|
YES_NO = "yes_no", "Yes/No"
|
|
|
|
survey = models.ForeignKey(Survey, related_name="questions", on_delete=models.CASCADE)
|
|
prompt = models.CharField(max_length=280)
|
|
slug = models.SlugField(max_length=140, blank=True)
|
|
help_text = models.CharField(max_length=240, blank=True)
|
|
type = models.CharField(
|
|
max_length=30,
|
|
choices=Type.choices,
|
|
default=Type.SHORT_TEXT,
|
|
)
|
|
is_required = models.BooleanField(default=True)
|
|
position = models.PositiveIntegerField(default=0)
|
|
min_value = models.IntegerField(default=1, blank=True, null=True)
|
|
max_value = models.IntegerField(default=5, blank=True, null=True)
|
|
|
|
class Meta:
|
|
ordering = ["position", "id"]
|
|
unique_together = ("survey", "slug")
|
|
|
|
def __str__(self) -> str:
|
|
return self.prompt
|
|
|
|
def clean(self):
|
|
if self.type == self.Type.RATING:
|
|
if self.min_value is None or self.max_value is None:
|
|
raise ValidationError("Rating questions need minimum and maximum values.")
|
|
if self.min_value >= self.max_value:
|
|
raise ValidationError("The rating minimum must be lower than the maximum.")
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.slug:
|
|
self.slug = self._build_unique_slug()
|
|
super().save(*args, **kwargs)
|
|
|
|
def _build_unique_slug(self):
|
|
base = slugify(self.prompt)[:120] or "question"
|
|
candidate = base
|
|
suffix = 2
|
|
while (
|
|
SurveyQuestion.objects.filter(survey_id=self.survey_id, slug=candidate)
|
|
.exclude(pk=self.pk)
|
|
.exists()
|
|
):
|
|
candidate = f"{base[:110]}-{suffix}"
|
|
suffix += 1
|
|
return candidate
|
|
|
|
|
|
class SurveyChoice(models.Model):
|
|
question = models.ForeignKey(
|
|
SurveyQuestion,
|
|
related_name="choices",
|
|
on_delete=models.CASCADE,
|
|
)
|
|
label = models.CharField(max_length=180)
|
|
value = models.SlugField(max_length=140, blank=True)
|
|
position = models.PositiveIntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ["position", "id"]
|
|
unique_together = ("question", "value")
|
|
|
|
def __str__(self) -> str:
|
|
return self.label
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.value:
|
|
self.value = self._build_unique_value()
|
|
super().save(*args, **kwargs)
|
|
|
|
def _build_unique_value(self):
|
|
base = slugify(self.label)[:120] or "choice"
|
|
candidate = base
|
|
suffix = 2
|
|
while (
|
|
SurveyChoice.objects.filter(question_id=self.question_id, value=candidate)
|
|
.exclude(pk=self.pk)
|
|
.exists()
|
|
):
|
|
candidate = f"{base[:110]}-{suffix}"
|
|
suffix += 1
|
|
return candidate
|
|
|
|
|
|
class SurveyResponse(models.Model):
|
|
survey = models.ForeignKey(Survey, related_name="responses", on_delete=models.CASCADE)
|
|
respondent_email = models.EmailField(blank=True)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
ip_address = models.GenericIPAddressField(blank=True, null=True)
|
|
user_agent = models.CharField(max_length=255, blank=True)
|
|
submitted_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ["-submitted_at"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.survey} response #{self.pk}"
|
|
|
|
|
|
class SurveyAnswer(models.Model):
|
|
response = models.ForeignKey(
|
|
SurveyResponse,
|
|
related_name="answers",
|
|
on_delete=models.CASCADE,
|
|
)
|
|
question = models.ForeignKey(
|
|
SurveyQuestion,
|
|
related_name="answers",
|
|
on_delete=models.PROTECT,
|
|
)
|
|
value = models.JSONField(default=dict, blank=True)
|
|
selected_choices = models.ManyToManyField(
|
|
SurveyChoice,
|
|
related_name="answers",
|
|
blank=True,
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["question__position", "id"]
|
|
unique_together = ("response", "question")
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.question}: {self.value}"
|