improve surverys
This commit is contained in:
+121
-1
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from django import forms
|
||||
from django.forms import ModelMultipleChoiceField
|
||||
from django.contrib.auth.models import User
|
||||
@@ -14,9 +15,30 @@ class SurveyForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Survey
|
||||
fields = ["name", "description", "active", "anonymous", "authors_only", "author"]
|
||||
fields = [
|
||||
"name",
|
||||
"description",
|
||||
"before_text",
|
||||
"after_text",
|
||||
"active",
|
||||
"anonymous",
|
||||
"authors_only",
|
||||
"author"
|
||||
]
|
||||
widgets = {
|
||||
"description": forms.Textarea(attrs={"rows": 3}),
|
||||
"before_text": forms.Textarea(attrs={"rows": 3, "placeholder": "Instructions shown before the survey starts..."}),
|
||||
"after_text": forms.Textarea(attrs={"rows": 3, "placeholder": "Thank you message shown after submission..."}),
|
||||
}
|
||||
|
||||
|
||||
class SurveyQuestionForm(forms.ModelForm):
|
||||
# Form-only fields for validation rules
|
||||
min_value = forms.IntegerField(required=False, label="Minimum Value (Integer)")
|
||||
max_value = forms.IntegerField(required=False, label="Maximum Value (Integer)")
|
||||
min_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label="Minimum Date")
|
||||
max_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label="Maximum Date")
|
||||
|
||||
class Meta:
|
||||
model = SurveyQuestion
|
||||
fields = ["text", "question_type", "choices", "required", "position"]
|
||||
@@ -24,6 +46,52 @@ class SurveyQuestionForm(forms.ModelForm):
|
||||
"choices": forms.Textarea(attrs={"rows": 4, "placeholder": "Enter each choice on a new line"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Populate initial values for validation fields from validation_rules dict
|
||||
if self.instance and self.instance.pk and self.instance.validation_rules:
|
||||
rules = self.instance.validation_rules
|
||||
self.fields["min_value"].initial = rules.get("min_value")
|
||||
self.fields["max_value"].initial = rules.get("max_value")
|
||||
|
||||
min_date_val = rules.get("min_date")
|
||||
if min_date_val:
|
||||
try:
|
||||
self.fields["min_date"].initial = datetime.date.fromisoformat(min_date_val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
max_date_val = rules.get("max_date")
|
||||
if max_date_val:
|
||||
try:
|
||||
self.fields["max_date"].initial = datetime.date.fromisoformat(max_date_val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
# Build validation_rules dict
|
||||
rules = {}
|
||||
min_val = self.cleaned_data.get("min_value")
|
||||
max_val = self.cleaned_data.get("max_value")
|
||||
min_dt = self.cleaned_data.get("min_date")
|
||||
max_dt = self.cleaned_data.get("max_date")
|
||||
|
||||
if min_val is not None:
|
||||
rules["min_value"] = min_val
|
||||
if max_val is not None:
|
||||
rules["max_value"] = max_val
|
||||
if min_dt is not None:
|
||||
rules["min_date"] = min_dt.isoformat()
|
||||
if max_dt is not None:
|
||||
rules["max_date"] = max_dt.isoformat()
|
||||
|
||||
instance.validation_rules = rules
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class SurveyTakeForm(forms.Form):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.survey = kwargs.pop("survey")
|
||||
@@ -53,6 +121,54 @@ class SurveyTakeForm(forms.Form):
|
||||
choices=[(str(i), str(i)) for i in range(1, 6)],
|
||||
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
|
||||
)
|
||||
elif q.question_type == SurveyQuestion.QuestionType.INTEGER:
|
||||
rules = q.validation_rules or {}
|
||||
self.fields[field_name] = forms.IntegerField(
|
||||
label=q.text,
|
||||
required=q.required,
|
||||
min_value=rules.get("min_value"),
|
||||
max_value=rules.get("max_value"),
|
||||
widget=forms.NumberInput(attrs={"class": "form-control"})
|
||||
)
|
||||
elif q.question_type == SurveyQuestion.QuestionType.DATE:
|
||||
rules = q.validation_rules or {}
|
||||
attrs = {"type": "date", "class": "form-control"}
|
||||
if rules.get("min_date"):
|
||||
attrs["min"] = rules["min_date"]
|
||||
if rules.get("max_date"):
|
||||
attrs["max"] = rules["max_date"]
|
||||
self.fields[field_name] = forms.DateField(
|
||||
label=q.text,
|
||||
required=q.required,
|
||||
widget=forms.DateInput(attrs=attrs)
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
for q in self.questions:
|
||||
field_name = f"question_{q.pk}"
|
||||
val = cleaned_data.get(field_name)
|
||||
if val is not None and val != "":
|
||||
rules = q.validation_rules or {}
|
||||
if q.question_type == SurveyQuestion.QuestionType.INTEGER:
|
||||
min_val = rules.get("min_value")
|
||||
max_val = rules.get("max_value")
|
||||
if min_val is not None and val < min_val:
|
||||
self.add_error(field_name, f"Value must be at least {min_val}.")
|
||||
if max_val is not None and val > max_val:
|
||||
self.add_error(field_name, f"Value cannot exceed {max_val}.")
|
||||
elif q.question_type == SurveyQuestion.QuestionType.DATE:
|
||||
min_dt_str = rules.get("min_date")
|
||||
max_dt_str = rules.get("max_date")
|
||||
if min_dt_str:
|
||||
min_dt = datetime.date.fromisoformat(min_dt_str)
|
||||
if val < min_dt:
|
||||
self.add_error(field_name, f"Date cannot be before {min_dt_str}.")
|
||||
if max_dt_str:
|
||||
max_dt = datetime.date.fromisoformat(max_dt_str)
|
||||
if val > max_dt:
|
||||
self.add_error(field_name, f"Date cannot be after {max_dt_str}.")
|
||||
return cleaned_data
|
||||
|
||||
def save(self, user=None, cid=None, content_type=None, object_id=None, pre_or_post='GENERAL'):
|
||||
response_user = None if self.survey.anonymous else user
|
||||
@@ -78,5 +194,9 @@ class SurveyTakeForm(forms.Form):
|
||||
ans.choice_answer = val
|
||||
elif q.question_type == SurveyQuestion.QuestionType.RATING:
|
||||
ans.rating_answer = int(val)
|
||||
elif q.question_type == SurveyQuestion.QuestionType.INTEGER:
|
||||
ans.integer_answer = int(val)
|
||||
elif q.question_type == SurveyQuestion.QuestionType.DATE:
|
||||
ans.date_answer = val
|
||||
ans.save()
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-29 08:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('survey', '0002_survey_author_survey_authors_only'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='survey',
|
||||
name='after_text',
|
||||
field=models.TextField(blank=True, help_text='Text shown to the user after they complete the survey'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='survey',
|
||||
name='before_text',
|
||||
field=models.TextField(blank=True, help_text='Text shown to the user before they start the survey'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='surveyanswer',
|
||||
name='date_answer',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='surveyanswer',
|
||||
name='integer_answer',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='surveyquestion',
|
||||
name='validation_rules',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Extensible validation rules in JSON format (e.g. min_value, max_value, min_date, max_date)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='surveyquestion',
|
||||
name='question_type',
|
||||
field=models.CharField(choices=[('TEXT', 'Free Text'), ('CHOICE', 'Multiple Choice (Single Option)'), ('RATING', 'Rating (1-5 Likert scale)'), ('INTEGER', 'Integer / Number'), ('DATE', 'Date')], default='TEXT', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,8 @@ from generic.mixins import AuthorMixin
|
||||
class Survey(models.Model, AuthorMixin):
|
||||
name = models.CharField(max_length=200, help_text="Title of the survey")
|
||||
description = models.TextField(blank=True, help_text="Introductory text or description")
|
||||
before_text = models.TextField(blank=True, help_text="Text shown to the user before they start the survey")
|
||||
after_text = models.TextField(blank=True, help_text="Text shown to the user after they complete the survey")
|
||||
created_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -51,6 +53,8 @@ class SurveyQuestion(models.Model):
|
||||
TEXT = "TEXT", "Free Text"
|
||||
CHOICE = "CHOICE", "Multiple Choice (Single Option)"
|
||||
RATING = "RATING", "Rating (1-5 Likert scale)"
|
||||
INTEGER = "INTEGER", "Integer / Number"
|
||||
DATE = "DATE", "Date"
|
||||
|
||||
survey = models.ForeignKey(
|
||||
Survey,
|
||||
@@ -75,6 +79,12 @@ class SurveyQuestion(models.Model):
|
||||
default=0,
|
||||
help_text="Ordering position of the question."
|
||||
)
|
||||
validation_rules = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Extensible validation rules in JSON format (e.g. min_value, max_value, min_date, max_date)"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["position", "id"]
|
||||
@@ -151,6 +161,8 @@ class SurveyAnswer(models.Model):
|
||||
text_answer = models.TextField(blank=True, null=True)
|
||||
choice_answer = models.CharField(max_length=255, blank=True, null=True)
|
||||
rating_answer = models.IntegerField(blank=True, null=True)
|
||||
integer_answer = models.IntegerField(blank=True, null=True)
|
||||
date_answer = models.DateField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Answer to {self.question.id} for response {self.response.id}"
|
||||
|
||||
@@ -51,6 +51,22 @@
|
||||
<div class="mt-2 small text-info">
|
||||
<i class="bi bi-ui-checks-grid me-1"></i>Options: {{ q.get_choices_list|join:", " }}
|
||||
</div>
|
||||
{% elif q.question_type == 'INTEGER' %}
|
||||
{% if q.validation_rules.min_value is not None or q.validation_rules.max_value is not None %}
|
||||
<div class="mt-2 small text-info">
|
||||
<i class="bi bi-shield-check me-1"></i>Validation:
|
||||
{% if q.validation_rules.min_value is not None %}Min: {{ q.validation_rules.min_value }}{% endif %}
|
||||
{% if q.validation_rules.max_value is not None %}Max: {{ q.validation_rules.max_value }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif q.question_type == 'DATE' %}
|
||||
{% if q.validation_rules.min_date or q.validation_rules.max_date %}
|
||||
<div class="mt-2 small text-info">
|
||||
<i class="bi bi-shield-check me-1"></i>Validation:
|
||||
{% if q.validation_rules.min_date %}After: {{ q.validation_rules.min_date }}{% endif %}
|
||||
{% if q.validation_rules.max_date %}Before: {{ q.validation_rules.max_date }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -116,20 +132,24 @@ function confirmDeleteQuestion(id, text) {
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var typeField = document.getElementById("id_question_type");
|
||||
var choicesDiv = document.getElementById("div_id_choices");
|
||||
var minValDiv = document.getElementById("div_id_min_value");
|
||||
var maxValDiv = document.getElementById("div_id_max_value");
|
||||
var minDateDiv = document.getElementById("div_id_min_date");
|
||||
var maxDateDiv = document.getElementById("div_id_max_date");
|
||||
|
||||
function toggleChoices() {
|
||||
if (typeField && choicesDiv) {
|
||||
if (typeField.value === "CHOICE") {
|
||||
choicesDiv.style.display = "block";
|
||||
} else {
|
||||
choicesDiv.style.display = "none";
|
||||
}
|
||||
function toggleFields() {
|
||||
if (typeField) {
|
||||
if (choicesDiv) choicesDiv.style.display = (typeField.value === "CHOICE") ? "block" : "none";
|
||||
if (minValDiv) minValDiv.style.display = (typeField.value === "INTEGER") ? "block" : "none";
|
||||
if (maxValDiv) maxValDiv.style.display = (typeField.value === "INTEGER") ? "block" : "none";
|
||||
if (minDateDiv) minDateDiv.style.display = (typeField.value === "DATE") ? "block" : "none";
|
||||
if (maxDateDiv) maxDateDiv.style.display = (typeField.value === "DATE") ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
if (typeField) {
|
||||
typeField.addEventListener("change", toggleChoices);
|
||||
toggleChoices();
|
||||
typeField.addEventListener("change", toggleFields);
|
||||
toggleFields();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -11,12 +11,46 @@
|
||||
<p class="text-muted mb-0">Analytics and details for the feedback questions.</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'survey:survey_export_csv' survey.pk %}" class="btn btn-success">
|
||||
<a href="{% url 'survey:survey_export_csv' survey.pk %}?filter_context={{ filter_context|default:'' }}&filter_target={{ filter_target|default:'' }}" class="btn btn-success">
|
||||
<i class="bi bi-download me-1"></i>Export to CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Block -->
|
||||
<div class="card bg-dark border-secondary text-white shadow mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row align-items-end g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-muted">Filter by Context / Source</label>
|
||||
<select name="filter_context" class="form-select bg-black border-secondary text-white">
|
||||
<option value="">All Contexts (Pre/Post/General)</option>
|
||||
<option value="PRE" {% if filter_context == 'PRE' %}selected{% endif %}>Pre-Exam / Collection</option>
|
||||
<option value="POST" {% if filter_context == 'POST' %}selected{% endif %}>Post-Exam / Collection</option>
|
||||
<option value="GENERAL" {% if filter_context == 'GENERAL' %}selected{% endif %}>General / Standalone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-muted">Filter by Exam / Case Collection</label>
|
||||
<select name="filter_target" class="form-select bg-black border-secondary text-white">
|
||||
<option value="">All Exams & Collections</option>
|
||||
{% for target in targets %}
|
||||
{% with val=target.content_type_id|stringformat:"s"|add:":"|add:target.object_id|stringformat:"s" %}
|
||||
<option value="{{ val }}" {% if filter_target == val %}selected{% endif %}>
|
||||
{{ target.name }}
|
||||
</option>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
||||
<a href="{% url 'survey:survey_results' survey.pk %}" class="btn btn-secondary">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark border-secondary text-white text-center shadow p-3">
|
||||
@@ -117,6 +151,44 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- INTEGER QUESTION -->
|
||||
{% elif item.question.question_type == 'INTEGER' %}
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4 text-center border-end border-secondary py-3">
|
||||
<div class="text-muted mb-1">Average Response</div>
|
||||
<div class="display-3 fw-bold text-info">{{ item.average_integer }}</div>
|
||||
</div>
|
||||
<div class="col-md-8 ps-md-4 py-3">
|
||||
<div class="text-muted mb-2">Submitted Numbers</div>
|
||||
<div style="max-height: 120px; overflow-y: auto;">
|
||||
{% for ans in item.integer_answers %}
|
||||
<span class="badge bg-black border border-secondary text-white me-2 mb-2 p-2">{{ ans }}</span>
|
||||
{% empty %}
|
||||
<span class="text-muted small">No submissions yet.</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DATE QUESTION -->
|
||||
{% elif item.question.question_type == 'DATE' %}
|
||||
<div class="py-2">
|
||||
<details class="bg-black rounded p-3 border border-secondary">
|
||||
<summary class="text-primary cursor-pointer fw-semibold">
|
||||
Show all {{ item.date_answers|length }} date responses
|
||||
</summary>
|
||||
<div class="mt-3" style="max-height: 200px; overflow-y: auto;">
|
||||
{% for ans in item.date_answers %}
|
||||
<span class="badge bg-black border border-secondary text-white me-2 mb-2 p-2">
|
||||
<i class="bi bi-calendar-event me-1"></i>{{ ans|date:"Y-m-d" }}
|
||||
</span>
|
||||
{% empty %}
|
||||
<p class="text-muted small mb-0">No dates submitted yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% if survey.before_text %}
|
||||
<div class="alert alert-info border-secondary text-white mb-4" style="background-color: #1a202c;">
|
||||
{{ survey.before_text|linebreaksbr }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Please correct the errors below before submitting.
|
||||
@@ -110,8 +116,11 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Submit Feedback</button>
|
||||
{% if mandatory == 'false' and skip_key %}
|
||||
<a href="{% url 'survey:survey_skip' %}?next={{ next|urlencode }}&key={{ skip_key|urlencode }}" class="btn btn-outline-secondary btn-lg">Skip Survey</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
</div>
|
||||
<h2 class="card-title text-white mb-3">Thank you!</h2>
|
||||
<p class="card-text text-muted mb-4">Your feedback has been successfully submitted. We appreciate your response.</p>
|
||||
{% if after_text %}
|
||||
<p class="card-text text-muted mb-4">{{ after_text|linebreaksbr }}</p>
|
||||
{% else %}
|
||||
<p class="card-text text-muted mb-4">Your feedback has been successfully submitted. We appreciate your response.</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ next_url|default:'/' }}" class="btn btn-primary btn-lg px-5">
|
||||
Continue <i class="bi bi-arrow-right ms-1"></i>
|
||||
|
||||
@@ -118,3 +118,162 @@ def test_can_manage_survey_permission(admin_user, django_user_model):
|
||||
other_manager.groups.add(manager_group)
|
||||
assert can_manage_survey(other_manager, survey) is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_integer_and_date_validation():
|
||||
from survey.forms import SurveyTakeForm
|
||||
survey = Survey.objects.create(name="Validation Test Survey")
|
||||
|
||||
# 1. Integer question with min=10, max=50
|
||||
q_int = SurveyQuestion.objects.create(
|
||||
survey=survey,
|
||||
text="Enter age",
|
||||
question_type=SurveyQuestion.QuestionType.INTEGER,
|
||||
validation_rules={"min_value": 10, "max_value": 50}
|
||||
)
|
||||
|
||||
# 2. Date question with min="2026-01-01", max="2026-12-31"
|
||||
q_date = SurveyQuestion.objects.create(
|
||||
survey=survey,
|
||||
text="Select target date",
|
||||
question_type=SurveyQuestion.QuestionType.DATE,
|
||||
validation_rules={"min_date": "2026-01-01", "max_date": "2026-12-31"}
|
||||
)
|
||||
|
||||
# Valid submission
|
||||
form_valid = SurveyTakeForm(
|
||||
data={
|
||||
f"question_{q_int.pk}": 25,
|
||||
f"question_{q_date.pk}": "2026-06-15"
|
||||
},
|
||||
survey=survey
|
||||
)
|
||||
assert form_valid.is_valid() is True
|
||||
|
||||
# Out of bounds Integer submission
|
||||
form_invalid_int = SurveyTakeForm(
|
||||
data={
|
||||
f"question_{q_int.pk}": 5,
|
||||
f"question_{q_date.pk}": "2026-06-15"
|
||||
},
|
||||
survey=survey
|
||||
)
|
||||
assert form_invalid_int.is_valid() is False
|
||||
assert f"question_{q_int.pk}" in form_invalid_int.errors
|
||||
|
||||
# Out of bounds Date submission
|
||||
form_invalid_date = SurveyTakeForm(
|
||||
data={
|
||||
f"question_{q_int.pk}": 25,
|
||||
f"question_{q_date.pk}": "2027-01-01"
|
||||
},
|
||||
survey=survey
|
||||
)
|
||||
assert form_invalid_date.is_valid() is False
|
||||
assert f"question_{q_date.pk}" in form_invalid_date.errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_survey_skip(rf, admin_user):
|
||||
survey = Survey.objects.create(name="Optional pre-take survey")
|
||||
collection = CaseCollection.objects.create(name="Test Collection", pre_survey=survey, pre_survey_mandatory=False)
|
||||
|
||||
request = rf.get('/atlas/collection/1/take/')
|
||||
request.user = admin_user
|
||||
|
||||
# Initialize session middleware mock
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
middleware = SessionMiddleware(lambda req: None)
|
||||
middleware.process_request(request)
|
||||
request.session.save()
|
||||
|
||||
# Not skipped yet: should return redirection response
|
||||
redirect_response = check_survey_interception(request, collection)
|
||||
assert redirect_response is not None
|
||||
assert "mandatory=false" in redirect_response.url
|
||||
|
||||
# Skip by setting the session key
|
||||
session_key = f"skipped_pre_survey_{collection.pk}"
|
||||
request.session[session_key] = True
|
||||
request.session.save()
|
||||
|
||||
# Now check again: should NOT redirect
|
||||
no_redirect_response = check_survey_interception(request, collection)
|
||||
assert no_redirect_response is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mandatory_pre_and_post_survey(rf, admin_user):
|
||||
survey_pre = Survey.objects.create(name="Mandatory Pre")
|
||||
survey_post = Survey.objects.create(name="Mandatory Post")
|
||||
collection = CaseCollection.objects.create(
|
||||
name="Test Collection",
|
||||
pre_survey=survey_pre,
|
||||
pre_survey_mandatory=True,
|
||||
post_survey=survey_post,
|
||||
post_survey_mandatory=True
|
||||
)
|
||||
|
||||
request = rf.get('/atlas/collection/1/take/')
|
||||
request.user = admin_user
|
||||
|
||||
# Pre-take interception should happen and pass mandatory=true
|
||||
redirect_pre = check_survey_interception(request, collection)
|
||||
assert redirect_pre is not None
|
||||
assert "mandatory=true" in redirect_pre.url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_analytics_filtering(admin_user):
|
||||
from django.test import RequestFactory
|
||||
from survey.views import survey_results
|
||||
|
||||
survey = Survey.objects.create(name="Common Survey")
|
||||
q = SurveyQuestion.objects.create(
|
||||
survey=survey,
|
||||
text="Rate something",
|
||||
question_type=SurveyQuestion.QuestionType.INTEGER
|
||||
)
|
||||
|
||||
collection = CaseCollection.objects.create(name="Collection 1")
|
||||
ct = ContentType.objects.get_for_model(collection)
|
||||
|
||||
# Response 1: PRE-take
|
||||
r1 = SurveyResponse.objects.create(
|
||||
survey=survey,
|
||||
user=admin_user,
|
||||
content_type=ct,
|
||||
object_id=collection.pk,
|
||||
pre_or_post="PRE"
|
||||
)
|
||||
SurveyAnswer.objects.create(response=r1, question=q, integer_answer=15)
|
||||
|
||||
# Response 2: POST-take
|
||||
r2 = SurveyResponse.objects.create(
|
||||
survey=survey,
|
||||
user=admin_user,
|
||||
content_type=ct,
|
||||
object_id=collection.pk,
|
||||
pre_or_post="POST"
|
||||
)
|
||||
SurveyAnswer.objects.create(response=r2, question=q, integer_answer=45)
|
||||
|
||||
# Requesting results with filter_context='PRE'
|
||||
rf = RequestFactory()
|
||||
request_pre = rf.get('/survey/results/', {'filter_context': 'PRE'})
|
||||
request_pre.user = admin_user
|
||||
|
||||
response = survey_results(request_pre, survey.pk)
|
||||
# The rendered context should show responses_count = 1, and average_integer = 15.0
|
||||
context = response.context_data
|
||||
assert context['responses_count'] == 1
|
||||
assert context['analytics'][0]['average_integer'] == 15.0
|
||||
|
||||
# Requesting results with filter_context='POST'
|
||||
request_post = rf.get('/survey/results/', {'filter_context': 'POST'})
|
||||
request_post.user = admin_user
|
||||
response_post = survey_results(request_post, survey.pk)
|
||||
context_post = response_post.context_data
|
||||
assert context_post['responses_count'] == 1
|
||||
assert context_post['analytics'][0]['average_integer'] == 45.0
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ app_name = "survey"
|
||||
urlpatterns = [
|
||||
path("", views.survey_list, name="survey_list"),
|
||||
path("create/", views.survey_create, name="survey_create"),
|
||||
path("skip/", views.survey_skip, name="survey_skip"),
|
||||
path("<int:pk>/update/", views.survey_update, name="survey_update"),
|
||||
path("<int:pk>/delete/", views.survey_delete, name="survey_delete"),
|
||||
path("<int:pk>/questions/", views.survey_questions_edit, name="survey_questions_edit"),
|
||||
|
||||
+102
-13
@@ -159,6 +159,9 @@ def survey_take(request, pk):
|
||||
object_id = request.GET.get("object_id") or request.POST.get("object_id")
|
||||
next_url = request.GET.get("next") or request.POST.get("next") or "/"
|
||||
|
||||
mandatory = request.GET.get("mandatory") or request.POST.get("mandatory") or "true"
|
||||
skip_key = request.GET.get("skip_key") or request.POST.get("skip_key") or ""
|
||||
|
||||
# Handle CID auth
|
||||
cid = request.GET.get("cid") or request.POST.get("cid")
|
||||
passcode = request.GET.get("passcode") or request.POST.get("passcode")
|
||||
@@ -189,7 +192,7 @@ def survey_take(request, pk):
|
||||
object_id=int(object_id) if object_id and str(object_id).isdigit() else None,
|
||||
pre_or_post=pre_or_post
|
||||
)
|
||||
return render(request, "survey/survey_thanks.html", {"next_url": next_url})
|
||||
return render(request, "survey/survey_thanks.html", {"next_url": next_url, "after_text": survey.after_text})
|
||||
else:
|
||||
form = SurveyTakeForm(survey=survey)
|
||||
|
||||
@@ -202,6 +205,8 @@ def survey_take(request, pk):
|
||||
"content_type_id": content_type_id,
|
||||
"object_id": object_id,
|
||||
"next": next_url,
|
||||
"mandatory": mandatory,
|
||||
"skip_key": skip_key,
|
||||
})
|
||||
|
||||
@login_required
|
||||
@@ -210,26 +215,63 @@ def survey_results(request, pk):
|
||||
"""
|
||||
Scope: Staff/Admin.
|
||||
Functionality: Renders survey response metrics, analytics, and text answers.
|
||||
Supports filtering by source context and target collections/exams.
|
||||
"""
|
||||
survey = get_object_or_404(Survey, pk=pk)
|
||||
if not can_manage_survey(request.user, survey):
|
||||
raise PermissionDenied("You do not have permission to manage this survey.")
|
||||
responses_count = survey.responses.count()
|
||||
|
||||
questions = survey.questions.all()
|
||||
responses = survey.responses.all()
|
||||
|
||||
# Compile list of distinct exams/collections that have used this survey to build the filter dropdown
|
||||
targets = []
|
||||
seen_targets = set()
|
||||
for resp in responses:
|
||||
if resp.content_type_id and resp.object_id:
|
||||
key = (resp.content_type_id, resp.object_id)
|
||||
if key not in seen_targets:
|
||||
seen_targets.add(key)
|
||||
try:
|
||||
obj = resp.content_object
|
||||
if obj:
|
||||
targets.append({
|
||||
"content_type_id": resp.content_type_id,
|
||||
"object_id": resp.object_id,
|
||||
"name": str(obj)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Apply GET filters
|
||||
filter_context = request.GET.get("filter_context")
|
||||
if filter_context in [SurveyResponse.SurveyContext.PRE, SurveyResponse.SurveyContext.POST, SurveyResponse.SurveyContext.GENERAL]:
|
||||
responses = responses.filter(pre_or_post=filter_context)
|
||||
|
||||
filter_target = request.GET.get("filter_target")
|
||||
if filter_target:
|
||||
try:
|
||||
ct_id, obj_id = filter_target.split(":")
|
||||
responses = responses.filter(content_type_id=int(ct_id), object_id=int(obj_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
responses_count = responses.count()
|
||||
|
||||
analytics = []
|
||||
for q in questions:
|
||||
answers = q.answers.filter(response__in=responses)
|
||||
q_data = {
|
||||
"question": q,
|
||||
"answers_count": q.answers.count()
|
||||
"answers_count": answers.count()
|
||||
}
|
||||
|
||||
if q.question_type == SurveyQuestion.QuestionType.TEXT:
|
||||
q_data["text_answers"] = q.answers.values_list("text_answer", flat=True).exclude(text_answer__isnull=True).exclude(text_answer="")
|
||||
q_data["text_answers"] = answers.values_list("text_answer", flat=True).exclude(text_answer__isnull=True).exclude(text_answer="")
|
||||
|
||||
elif q.question_type == SurveyQuestion.QuestionType.CHOICE:
|
||||
# Count choices
|
||||
choice_counts = q.answers.values("choice_answer").annotate(count=Count("id")).order_by("-count")
|
||||
choice_counts = answers.values("choice_answer").annotate(count=Count("id")).order_by("-count")
|
||||
q_data["choice_counts"] = []
|
||||
for cc in choice_counts:
|
||||
ans_text = cc["choice_answer"]
|
||||
@@ -243,11 +285,11 @@ def survey_results(request, pk):
|
||||
|
||||
elif q.question_type == SurveyQuestion.QuestionType.RATING:
|
||||
# Stats for rating
|
||||
avg = q.answers.aggregate(avg=Avg("rating_answer"))["avg"]
|
||||
avg = answers.aggregate(avg=Avg("rating_answer"))["avg"]
|
||||
q_data["average_rating"] = round(avg, 2) if avg is not None else "N/A"
|
||||
|
||||
# Rating counts
|
||||
rating_counts = q.answers.values("rating_answer").annotate(count=Count("id"))
|
||||
rating_counts = answers.values("rating_answer").annotate(count=Count("id"))
|
||||
counts_dict = {i: 0 for i in range(1, 6)}
|
||||
for rc in rating_counts:
|
||||
r = rc["rating_answer"]
|
||||
@@ -264,12 +306,23 @@ def survey_results(request, pk):
|
||||
"percentage": pct
|
||||
})
|
||||
|
||||
elif q.question_type == SurveyQuestion.QuestionType.INTEGER:
|
||||
avg = answers.aggregate(avg=Avg("integer_answer"))["avg"]
|
||||
q_data["average_integer"] = round(avg, 2) if avg is not None else "N/A"
|
||||
q_data["integer_answers"] = list(answers.values_list("integer_answer", flat=True).exclude(integer_answer__isnull=True))
|
||||
|
||||
elif q.question_type == SurveyQuestion.QuestionType.DATE:
|
||||
q_data["date_answers"] = list(answers.values_list("date_answer", flat=True).exclude(date_answer__isnull=True))
|
||||
|
||||
analytics.append(q_data)
|
||||
|
||||
return render(request, "survey/survey_results.html", {
|
||||
"survey": survey,
|
||||
"responses_count": responses_count,
|
||||
"analytics": analytics
|
||||
"analytics": analytics,
|
||||
"targets": targets,
|
||||
"filter_context": filter_context,
|
||||
"filter_target": filter_target,
|
||||
})
|
||||
|
||||
@login_required
|
||||
@@ -277,13 +330,28 @@ def survey_results(request, pk):
|
||||
def survey_export_csv(request, pk):
|
||||
"""
|
||||
Scope: Staff/Admin.
|
||||
Functionality: Exports all responses for a survey as a downloadable CSV.
|
||||
Functionality: Exports filtered responses for a survey as a downloadable CSV.
|
||||
"""
|
||||
survey = get_object_or_404(Survey, pk=pk)
|
||||
if not can_manage_survey(request.user, survey):
|
||||
raise PermissionDenied("You do not have permission to manage this survey.")
|
||||
questions = list(survey.questions.all().order_by("position", "id"))
|
||||
|
||||
questions = list(survey.questions.all().order_by("position", "id"))
|
||||
responses = survey.responses.all()
|
||||
|
||||
# Apply GET filters to match CSV export output
|
||||
filter_context = request.GET.get("filter_context")
|
||||
if filter_context in [SurveyResponse.SurveyContext.PRE, SurveyResponse.SurveyContext.POST, SurveyResponse.SurveyContext.GENERAL]:
|
||||
responses = responses.filter(pre_or_post=filter_context)
|
||||
|
||||
filter_target = request.GET.get("filter_target")
|
||||
if filter_target:
|
||||
try:
|
||||
ct_id, obj_id = filter_target.split(":")
|
||||
responses = responses.filter(content_type_id=int(ct_id), object_id=int(obj_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
response = HttpResponse(content_type="text/csv")
|
||||
response["Content-Disposition"] = f'attachment; filename="survey_{survey.pk}_responses.csv"'
|
||||
|
||||
@@ -296,7 +364,7 @@ def survey_export_csv(request, pk):
|
||||
writer.writerow(headers)
|
||||
|
||||
# Data rows
|
||||
for resp in survey.responses.all():
|
||||
for resp in responses:
|
||||
user_str = resp.user.username if resp.user else ""
|
||||
cid_str = str(resp.cid) if resp.cid else ""
|
||||
ct_str = f"{resp.content_type.app_label}.{resp.content_type.model}" if resp.content_type else ""
|
||||
@@ -324,6 +392,10 @@ def survey_export_csv(request, pk):
|
||||
row.append(ans.choice_answer or "")
|
||||
elif q.question_type == SurveyQuestion.QuestionType.RATING:
|
||||
row.append(str(ans.rating_answer) if ans.rating_answer is not None else "")
|
||||
elif q.question_type == SurveyQuestion.QuestionType.INTEGER:
|
||||
row.append(str(ans.integer_answer) if ans.integer_answer is not None else "")
|
||||
elif q.question_type == SurveyQuestion.QuestionType.DATE:
|
||||
row.append(ans.date_answer.strftime("%Y-%m-%d") if ans.date_answer else "")
|
||||
|
||||
writer.writerow(row)
|
||||
|
||||
@@ -332,9 +404,13 @@ def survey_export_csv(request, pk):
|
||||
def check_survey_interception(request, exam_or_collection, cid=None, passcode=None):
|
||||
"""
|
||||
Checks if a pre_survey is configured for this collection/exam and if the user/cid
|
||||
has already responded to it. If not, redirects them to take the survey.
|
||||
has already responded to it or skipped it. If not, redirects them to take the survey.
|
||||
"""
|
||||
if exam_or_collection.pre_survey:
|
||||
session_key = f"skipped_pre_survey_{exam_or_collection.pk}"
|
||||
if request.session.get(session_key):
|
||||
return None
|
||||
|
||||
user_param = request.user if request.user.is_authenticated else None
|
||||
cid_param = int(cid) if cid and str(cid).isdigit() else None
|
||||
ct = ContentType.objects.get_for_model(exam_or_collection)
|
||||
@@ -351,8 +427,21 @@ def check_survey_interception(request, exam_or_collection, cid=None, passcode=No
|
||||
if not has_responded:
|
||||
from django.urls import reverse
|
||||
survey_url = reverse("survey:survey_take", kwargs={"pk": exam_or_collection.pre_survey.pk})
|
||||
params = f"?pre_or_post=PRE&content_type_id={ct.id}&object_id={exam_or_collection.pk}&next={request.get_full_path()}"
|
||||
is_mandatory = getattr(exam_or_collection, "pre_survey_mandatory", False)
|
||||
mandatory_str = "true" if is_mandatory else "false"
|
||||
params = f"?pre_or_post=PRE&content_type_id={ct.id}&object_id={exam_or_collection.pk}&next={request.get_full_path()}&mandatory={mandatory_str}&skip_key={session_key}"
|
||||
if cid and passcode:
|
||||
params += f"&cid={cid}&passcode={passcode}"
|
||||
return redirect(f"{survey_url}{params}")
|
||||
return None
|
||||
|
||||
def survey_skip(request):
|
||||
"""
|
||||
Scope: Candidates/Users.
|
||||
Functionality: Sets a session variable to bypass an optional survey, then redirects.
|
||||
"""
|
||||
next_url = request.GET.get("next") or "/"
|
||||
key = request.GET.get("key")
|
||||
if key:
|
||||
request.session[key] = True
|
||||
return redirect(next_url)
|
||||
|
||||
Reference in New Issue
Block a user