improve surverys

This commit is contained in:
Ross
2026-06-29 09:35:12 +01:00
parent 79ba4db4bf
commit c34dcdfd5e
26 changed files with 911 additions and 39 deletions
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0028_exam_post_survey_exam_pre_survey'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='exam',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
+2
View File
@@ -362,7 +362,9 @@ class CaseCollectionForm(ModelForm):
"answer_entry_grace_period",
"prerequisites",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
Fieldset(
"Custom Start Screen",
"start_screen_content",
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0109_casecollection_post_survey_casecollection_pre_survey'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='casecollection',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
+20 -9
View File
@@ -76,21 +76,32 @@
<h3>Surveys & Feedback</h3>
<p>The platform supports reusable and shareable feedback forms and surveys that can be configured by administrators and staff to assess educational improvement and collect feedback.</p>
<p>The platform supports reusable, shareable, and mandatory feedback forms and surveys that can be configured by administrators to assess educational improvement and collect feedback.</p>
<h5>Managing Surveys (Staff/Admins)</h5>
<ul>
<li><strong>Create Surveys:</strong> Navigate to the <em>Survey Dashboard</em> to create a survey, define its name, description, and decide if responses should be anonymous.</li>
<li><strong>Question Types:</strong> You can add questions of three types:
<li><strong>Create Surveys:</strong> Navigate to the <em>Survey Dashboard</em> to create a survey, customize its name, description, decide if responses should be anonymous, and specify:
<ul>
<li><em>Free Text:</em> For qualitative comments.</li>
<li><em>Multiple Choice:</em> For single-select questions (entered one option per line).</li>
<li><em>Rating Scale:</em> 1-to-5 Likert scale ratings.</li>
<li><em>Before Text:</em> Custom instructions shown to candidates before they begin.</li>
<li><em>After Text:</em> Custom thank-you message shown upon submission.</li>
</ul>
</li>
<li><strong>Question Reordering:</strong> Adjust question ordering using the numeric inputs and click <em>Update Order</em>.</li>
<li><strong>Linking to Case Collections & Exams:</strong> Expose surveys by assigning them as <em>Pre-take</em> or <em>Post-take</em> surveys in the collection/exam edit screens. Candidates are automatically redirected to complete the Pre-take survey before they can begin, and are prompted to complete the Post-take survey once they finish.</li>
<li><strong>Analytics & CSV Export:</strong> Click <em>Analytics</em> on the dashboard to view aggregated rating averages, response counts, graphical progress bar breakdowns, and free-text replies. You can export complete response details to CSV format by clicking <em>Export CSV</em>.</li>
<li><strong>Question Types & Validation:</strong> You can add questions of multiple types:
<ul>
<li><em>Free Text:</em> For qualitative comments.</li>
<li><em>Multiple Choice:</em> For single-select options (entered one option per line).</li>
<li><em>Rating Scale:</em> 1-to-5 Likert scale ratings.</li>
<li><em>Integer / Number:</em> Enforces numerical input. You can set custom minimum/maximum validation rules.</li>
<li><em>Date:</em> Enforces date input using a calendar picker. You can configure custom minimum/maximum date range limits.</li>
</ul>
</li>
<li><strong>Mandatory & Optional Flows:</strong> You can assign surveys as <em>Pre-take</em> or <em>Post-take</em> surveys in collection/exam edit screens:
<ul>
<li><em>Mandatory:</em> Candidates must fill out the pre-survey to start, and must fill out the post-survey before they can view their results/scores.</li>
<li><em>Optional:</em> Candidates will be prompted to fill out the surveys, but can choose to skip them using the "Skip Survey" option.</li>
</ul>
</li>
<li><strong>Analytics & CSV Export:</strong> The <em>Analytics Dashboard</em> displays rating averages, progress bar breakdowns, integer statistics, and text answers. You can filter results by survey source (Pre-take, Post-take, or Standalone) and target exams/collections. Results can be exported as a filtered CSV.</li>
</ul>
<h5>Taking Standalone/General Surveys</h5>
+13 -4
View File
@@ -7755,10 +7755,19 @@ def collection_take_overview(
if not has_responded:
from django.urls import reverse
survey_url = reverse("survey:survey_take", kwargs={"pk": collection.post_survey.pk})
params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={collection.pk}&next={request.get_full_path()}"
if cid and passcode:
params += f"&cid={cid}&passcode={passcode}"
post_survey_url = f"{survey_url}{params}"
is_mandatory = getattr(collection, "post_survey_mandatory", False)
if is_mandatory:
params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={collection.pk}&next={request.get_full_path()}&mandatory=true"
if cid and passcode:
params += f"&cid={cid}&passcode={passcode}"
return redirect(f"{survey_url}{params}")
else:
session_key = f"skipped_post_survey_{collection.pk}"
if not request.session.get(session_key):
params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={collection.pk}&next={request.get_full_path()}&mandatory=false&skip_key={session_key}"
if cid and passcode:
params += f"&cid={cid}&passcode={passcode}"
post_survey_url = f"{survey_url}{params}"
return render(
request,
+4
View File
@@ -147,7 +147,9 @@ class ExamFormMixin:
"publish_results",
"archive",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
Accordion(
AccordionGroup("Date restrictions",
"restrict_to_dates",
@@ -218,7 +220,9 @@ class ExamFormMixin:
"archive",
"results_supervisor_visible",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
# "cid_user_groups",
# "user_user_groups",
# "author",
+8
View File
@@ -732,6 +732,14 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
related_name="%(app_label)s_%(class)s_post_surveys",
help_text="Survey to be filled out AFTER completing this collection/exam."
)
pre_survey_mandatory = models.BooleanField(
default=False,
help_text="If checked, candidates must complete the pre-take survey to start."
)
post_survey_mandatory = models.BooleanField(
default=False,
help_text="If checked, candidates must complete the post-take survey to view results."
)
class Meta:
abstract = True
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('longs', '0036_exam_post_survey_exam_pre_survey'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='exam',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('physics', '0019_exam_post_survey_exam_pre_survey'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='exam',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
@@ -10,6 +10,15 @@
</span>
{% include "exam_clock.html" %}
{% if post_survey_url %}
<div class="alert alert-info d-flex align-items-center justify-content-between my-3" role="alert">
<div>
<strong>Feedback Survey:</strong> We value your feedback! Please take a quick survey about this exam.
</div>
<a href="{{ post_survey_url }}" class="btn btn-sm btn-info text-white">Take Survey</a>
</div>
{% endif %}
<h2>Exam: {{exam}}</h2>
{% if exam.publish_results %}
+64
View File
@@ -138,6 +138,12 @@ def exam_start(request, pk):
if not exam.active:
return exam_inactive(request, context={"exam": exam})
# Check if pre-survey is required and not yet taken
from survey.views import check_survey_interception
response = check_survey_interception(request, exam)
if response:
return response
return render(
request,
"physics/exam_start.html",
@@ -179,6 +185,12 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
exam.check_user_can_take(cid, passcode, request.user)
# Check if pre-survey is required and not yet taken
from survey.views import check_survey_interception
response = check_survey_interception(request, exam, cid, passcode)
if response:
return response
questions = exam.get_questions()
if cid is not None:
@@ -212,6 +224,51 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
# Check if post-survey is mandatory and not yet taken
if exam.post_survey and getattr(exam, "post_survey_mandatory", False) and cid_user_exam.completed:
from survey.models import SurveyResponse
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)
has_responded = SurveyResponse.objects.filter(
survey=exam.post_survey,
user=user_param,
cid=cid_param,
content_type=ct,
object_id=exam.pk,
pre_or_post=SurveyResponse.SurveyContext.POST
).exists()
if not has_responded:
from django.urls import reverse
survey_url = reverse("survey:survey_take", kwargs={"pk": exam.post_survey.pk})
params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={exam.pk}&next={request.get_full_path()}&mandatory=true"
if cid and passcode:
params += f"&cid={cid}&passcode={passcode}"
return redirect(f"{survey_url}{params}")
post_survey_url = None
if exam.post_survey and not getattr(exam, "post_survey_mandatory", False):
from survey.models import SurveyResponse
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)
has_responded = SurveyResponse.objects.filter(
survey=exam.post_survey,
user=user_param,
cid=cid_param,
content_type=ct,
object_id=exam.pk,
pre_or_post=SurveyResponse.SurveyContext.POST
).exists()
if not has_responded:
session_key = f"skipped_post_survey_{exam.pk}"
if not request.session.get(session_key):
from django.urls import reverse
survey_url = reverse("survey:survey_take", kwargs={"pk": exam.post_survey.pk})
params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={exam.pk}&next={request.get_full_path()}&mandatory=false&skip_key={session_key}"
if cid and passcode:
params += f"&cid={cid}&passcode={passcode}"
post_survey_url = f"{survey_url}{params}"
return render(
request,
@@ -224,6 +281,7 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
"exam_length": len(questions),
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"post_survey_url": post_survey_url,
},
)
@@ -245,6 +303,12 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
exam.check_user_can_take(cid, passcode, request.user)
# Check if pre-survey is required and not yet taken
from survey.views import check_survey_interception
response = check_survey_interception(request, exam, cid, passcode)
if response:
return response
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
# Canonical questions list for consistent indexing/navigation
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rapids', '0018_exam_post_survey_exam_pre_survey'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='exam',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sbas', '0024_exam_post_survey_exam_pre_survey'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='exam',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
@@ -10,6 +10,16 @@
</span>
{% include "exam_clock.html" %}
{% if post_survey_url %}
<div class="alert alert-info d-flex align-items-center justify-content-between my-3" role="alert">
<div>
<strong>Feedback Survey:</strong> We value your feedback! Please take a quick survey about this exam.
</div>
<a href="{{ post_survey_url }}" class="btn btn-sm btn-info text-white">Take Survey</a>
</div>
{% endif %}
<h2>Exam: {{exam}}</h2>
{% if can_edit %}
+65
View File
@@ -300,6 +300,12 @@ def exam_start(request, pk):
if not exam.active:
return exam_inactive(request, context={"exam": exam})
# Check if pre-survey is required and not yet taken
from survey.views import check_survey_interception
response = check_survey_interception(request, exam)
if response:
return response
return render(
request,
"sbas/exam_start.html",
@@ -337,6 +343,12 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
exam.check_user_can_take(cid, passcode, request.user)
# Check if pre-survey is required and not yet taken
from survey.views import check_survey_interception
response = check_survey_interception(request, exam, cid, passcode)
if response:
return response
can_edit = exam.check_user_can_edit(request.user)
questions = exam.get_questions()
@@ -361,6 +373,52 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
# Check if post-survey is mandatory and not yet taken
if exam.post_survey and getattr(exam, "post_survey_mandatory", False) and cid_user_exam.completed:
from survey.models import SurveyResponse
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)
has_responded = SurveyResponse.objects.filter(
survey=exam.post_survey,
user=user_param,
cid=cid_param,
content_type=ct,
object_id=exam.pk,
pre_or_post=SurveyResponse.SurveyContext.POST
).exists()
if not has_responded:
from django.urls import reverse
survey_url = reverse("survey:survey_take", kwargs={"pk": exam.post_survey.pk})
params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={exam.pk}&next={request.get_full_path()}&mandatory=true"
if cid and passcode:
params += f"&cid={cid}&passcode={passcode}"
return redirect(f"{survey_url}{params}")
post_survey_url = None
if exam.post_survey and not getattr(exam, "post_survey_mandatory", False):
from survey.models import SurveyResponse
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)
has_responded = SurveyResponse.objects.filter(
survey=exam.post_survey,
user=user_param,
cid=cid_param,
content_type=ct,
object_id=exam.pk,
pre_or_post=SurveyResponse.SurveyContext.POST
).exists()
if not has_responded:
session_key = f"skipped_post_survey_{exam.pk}"
if not request.session.get(session_key):
from django.urls import reverse
survey_url = reverse("survey:survey_take", kwargs={"pk": exam.post_survey.pk})
params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={exam.pk}&next={request.get_full_path()}&mandatory=false&skip_key={session_key}"
if cid and passcode:
params += f"&cid={cid}&passcode={passcode}"
post_survey_url = f"{survey_url}{params}"
return render(
request,
"sbas/exam_take_overview.html",
@@ -373,6 +431,7 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"can_edit": can_edit,
"post_survey_url": post_survey_url,
},
)
@@ -386,6 +445,12 @@ def exam_take(request, pk: int, sk: int, cid: int = None, passcode: str = None):
exam.check_user_can_take(cid, passcode, request.user)
# Check if pre-survey is required and not yet taken
from survey.views import check_survey_interception
response = check_survey_interception(request, exam, cid, passcode)
if response:
return response
# log start only when a new CidUserExam is created
content_type = ContentType.objects.get_for_model(exam)
existing = False
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shorts', '0012_exam_post_survey_exam_pre_survey'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='exam',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
+121 -1
View File
@@ -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),
),
]
+12
View File
@@ -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>
+73 -1
View File
@@ -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>
+10 -1
View File
@@ -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>
+5 -1
View File
@@ -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>
+159
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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)