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.
+
The platform supports reusable, shareable, and mandatory feedback forms and surveys that can be configured by administrators to assess educational improvement and collect feedback.
Managing Surveys (Staff/Admins)
-
Create Surveys: Navigate to the Survey Dashboard to create a survey, define its name, description, and decide if responses should be anonymous.
-
Question Types: You can add questions of three types:
+
Create Surveys: Navigate to the Survey Dashboard to create a survey, customize its name, description, decide if responses should be anonymous, and specify:
-
Free Text: For qualitative comments.
-
Multiple Choice: For single-select questions (entered one option per line).
-
Rating Scale: 1-to-5 Likert scale ratings.
+
Before Text: Custom instructions shown to candidates before they begin.
+
After Text: Custom thank-you message shown upon submission.
-
Question Reordering: Adjust question ordering using the numeric inputs and click Update Order.
-
Linking to Case Collections & Exams: Expose surveys by assigning them as Pre-take or Post-take 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.
-
Analytics & CSV Export: Click Analytics 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 Export CSV.
+
Question Types & Validation: You can add questions of multiple types:
+
+
Free Text: For qualitative comments.
+
Multiple Choice: For single-select options (entered one option per line).
+
Rating Scale: 1-to-5 Likert scale ratings.
+
Integer / Number: Enforces numerical input. You can set custom minimum/maximum validation rules.
+
Date: Enforces date input using a calendar picker. You can configure custom minimum/maximum date range limits.
+
+
+
Mandatory & Optional Flows: You can assign surveys as Pre-take or Post-take surveys in collection/exam edit screens:
+
+
Mandatory: Candidates must fill out the pre-survey to start, and must fill out the post-survey before they can view their results/scores.
+
Optional: Candidates will be prompted to fill out the surveys, but can choose to skip them using the "Skip Survey" option.
+
+
+
Analytics & CSV Export: The Analytics Dashboard 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.
Taking Standalone/General Surveys
diff --git a/atlas/views.py b/atlas/views.py
index ebac4a7a..654305fe 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -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,
diff --git a/generic/forms.py b/generic/forms.py
index 15c47976..b187b637 100755
--- a/generic/forms.py
+++ b/generic/forms.py
@@ -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",
diff --git a/generic/models.py b/generic/models.py
index 1de0d07a..b7eff8c3 100644
--- a/generic/models.py
+++ b/generic/models.py
@@ -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
diff --git a/longs/migrations/0037_exam_post_survey_mandatory_exam_pre_survey_mandatory.py b/longs/migrations/0037_exam_post_survey_mandatory_exam_pre_survey_mandatory.py
new file mode 100644
index 00000000..e031b870
--- /dev/null
+++ b/longs/migrations/0037_exam_post_survey_mandatory_exam_pre_survey_mandatory.py
@@ -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.'),
+ ),
+ ]
diff --git a/physics/migrations/0020_exam_post_survey_mandatory_exam_pre_survey_mandatory.py b/physics/migrations/0020_exam_post_survey_mandatory_exam_pre_survey_mandatory.py
new file mode 100644
index 00000000..3249143b
--- /dev/null
+++ b/physics/migrations/0020_exam_post_survey_mandatory_exam_pre_survey_mandatory.py
@@ -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.'),
+ ),
+ ]
diff --git a/physics/templates/physics/exam_take_overview.html b/physics/templates/physics/exam_take_overview.html
index 102ce95c..b1c58af8 100644
--- a/physics/templates/physics/exam_take_overview.html
+++ b/physics/templates/physics/exam_take_overview.html
@@ -10,6 +10,15 @@
{% include "exam_clock.html" %}
+ {% if post_survey_url %}
+
+
+ Feedback Survey: We value your feedback! Please take a quick survey about this exam.
+
{% if can_edit %}
diff --git a/sbas/views.py b/sbas/views.py
index 84547b99..aee12f5a 100644
--- a/sbas/views.py
+++ b/sbas/views.py
@@ -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
diff --git a/shorts/migrations/0013_exam_post_survey_mandatory_exam_pre_survey_mandatory.py b/shorts/migrations/0013_exam_post_survey_mandatory_exam_pre_survey_mandatory.py
new file mode 100644
index 00000000..b7bba93d
--- /dev/null
+++ b/shorts/migrations/0013_exam_post_survey_mandatory_exam_pre_survey_mandatory.py
@@ -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.'),
+ ),
+ ]
diff --git a/survey/forms.py b/survey/forms.py
index cb7963f0..12295d09 100644
--- a/survey/forms.py
+++ b/survey/forms.py
@@ -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
diff --git a/survey/migrations/0003_survey_after_text_survey_before_text_and_more.py b/survey/migrations/0003_survey_after_text_survey_before_text_and_more.py
new file mode 100644
index 00000000..d56da8ee
--- /dev/null
+++ b/survey/migrations/0003_survey_after_text_survey_before_text_and_more.py
@@ -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),
+ ),
+ ]
diff --git a/survey/models.py b/survey/models.py
index e8b57dab..8a8777f1 100644
--- a/survey/models.py
+++ b/survey/models.py
@@ -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}"
diff --git a/survey/templates/survey/survey_questions_edit.html b/survey/templates/survey/survey_questions_edit.html
index 8bb7acfb..fc0fe00b 100644
--- a/survey/templates/survey/survey_questions_edit.html
+++ b/survey/templates/survey/survey_questions_edit.html
@@ -51,6 +51,22 @@
Options: {{ q.get_choices_list|join:", " }}
+ {% elif q.question_type == 'INTEGER' %}
+ {% if q.validation_rules.min_value is not None or q.validation_rules.max_value is not None %}
+
+ 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 %}
+
+ {% endif %}
+ {% elif q.question_type == 'DATE' %}
+ {% if q.validation_rules.min_date or q.validation_rules.max_date %}
+