From c34dcdfd5e4b0a9a80f008e20e45409a4cb99308 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 29 Jun 2026 09:35:12 +0100 Subject: [PATCH] improve surverys --- ...vey_mandatory_exam_pre_survey_mandatory.py | 23 +++ atlas/forms.py | 2 + ...llection_post_survey_mandatory_and_more.py | 23 +++ atlas/templates/atlas/help.html | 29 +++- atlas/views.py | 17 +- generic/forms.py | 4 + generic/models.py | 8 + ...vey_mandatory_exam_pre_survey_mandatory.py | 23 +++ ...vey_mandatory_exam_pre_survey_mandatory.py | 23 +++ .../templates/physics/exam_take_overview.html | 9 + physics/views.py | 64 +++++++ ...vey_mandatory_exam_pre_survey_mandatory.py | 23 +++ ...vey_mandatory_exam_pre_survey_mandatory.py | 23 +++ sbas/templates/sbas/exam_take_overview.html | 10 ++ sbas/views.py | 65 +++++++ ...vey_mandatory_exam_pre_survey_mandatory.py | 23 +++ survey/forms.py | 122 +++++++++++++- ..._after_text_survey_before_text_and_more.py | 43 +++++ survey/models.py | 12 ++ .../survey/survey_questions_edit.html | 38 ++++- survey/templates/survey/survey_results.html | 74 +++++++- survey/templates/survey/survey_take.html | 11 +- survey/templates/survey/survey_thanks.html | 6 +- survey/tests/test_survey.py | 159 ++++++++++++++++++ survey/urls.py | 1 + survey/views.py | 115 +++++++++++-- 26 files changed, 911 insertions(+), 39 deletions(-) create mode 100644 anatomy/migrations/0029_exam_post_survey_mandatory_exam_pre_survey_mandatory.py create mode 100644 atlas/migrations/0110_casecollection_post_survey_mandatory_and_more.py create mode 100644 longs/migrations/0037_exam_post_survey_mandatory_exam_pre_survey_mandatory.py create mode 100644 physics/migrations/0020_exam_post_survey_mandatory_exam_pre_survey_mandatory.py create mode 100644 rapids/migrations/0019_exam_post_survey_mandatory_exam_pre_survey_mandatory.py create mode 100644 sbas/migrations/0025_exam_post_survey_mandatory_exam_pre_survey_mandatory.py create mode 100644 shorts/migrations/0013_exam_post_survey_mandatory_exam_pre_survey_mandatory.py create mode 100644 survey/migrations/0003_survey_after_text_survey_before_text_and_more.py diff --git a/anatomy/migrations/0029_exam_post_survey_mandatory_exam_pre_survey_mandatory.py b/anatomy/migrations/0029_exam_post_survey_mandatory_exam_pre_survey_mandatory.py new file mode 100644 index 00000000..6f5f06d2 --- /dev/null +++ b/anatomy/migrations/0029_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 = [ + ('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.'), + ), + ] diff --git a/atlas/forms.py b/atlas/forms.py index 83185669..67b93386 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -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", diff --git a/atlas/migrations/0110_casecollection_post_survey_mandatory_and_more.py b/atlas/migrations/0110_casecollection_post_survey_mandatory_and_more.py new file mode 100644 index 00000000..4250575c --- /dev/null +++ b/atlas/migrations/0110_casecollection_post_survey_mandatory_and_more.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 = [ + ('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.'), + ), + ] diff --git a/atlas/templates/atlas/help.html b/atlas/templates/atlas/help.html index 20e86ea9..4d252ee0 100644 --- a/atlas/templates/atlas/help.html +++ b/atlas/templates/atlas/help.html @@ -76,21 +76,32 @@

Surveys & Feedback

-

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)
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 %} + + {% endif %} +

Exam: {{exam}}

{% if exam.publish_results %} diff --git a/physics/views.py b/physics/views.py index ba3a5ab7..8c35bd2a 100644 --- a/physics/views.py +++ b/physics/views.py @@ -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 diff --git a/rapids/migrations/0019_exam_post_survey_mandatory_exam_pre_survey_mandatory.py b/rapids/migrations/0019_exam_post_survey_mandatory_exam_pre_survey_mandatory.py new file mode 100644 index 00000000..173611e7 --- /dev/null +++ b/rapids/migrations/0019_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 = [ + ('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.'), + ), + ] diff --git a/sbas/migrations/0025_exam_post_survey_mandatory_exam_pre_survey_mandatory.py b/sbas/migrations/0025_exam_post_survey_mandatory_exam_pre_survey_mandatory.py new file mode 100644 index 00000000..5b8868b8 --- /dev/null +++ b/sbas/migrations/0025_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 = [ + ('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.'), + ), + ] diff --git a/sbas/templates/sbas/exam_take_overview.html b/sbas/templates/sbas/exam_take_overview.html index 2da5993d..d03ef26e 100644 --- a/sbas/templates/sbas/exam_take_overview.html +++ b/sbas/templates/sbas/exam_take_overview.html @@ -10,6 +10,16 @@ {% include "exam_clock.html" %} + + {% if post_survey_url %} + + {% endif %} +

Exam: {{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 %} +
+ 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 %} +
+ {% endif %} {% endif %}
@@ -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(); } }); diff --git a/survey/templates/survey/survey_results.html b/survey/templates/survey/survey_results.html index ee2bfde5..0c5840db 100644 --- a/survey/templates/survey/survey_results.html +++ b/survey/templates/survey/survey_results.html @@ -11,12 +11,46 @@

Analytics and details for the feedback questions.

- + Export to CSV
+ +
+
+
+
+ + +
+
+ + +
+
+ + Reset +
+
+
+
+
@@ -117,6 +151,44 @@
+ + + {% elif item.question.question_type == 'INTEGER' %} +
+
+
Average Response
+
{{ item.average_integer }}
+
+
+
Submitted Numbers
+
+ {% for ans in item.integer_answers %} + {{ ans }} + {% empty %} + No submissions yet. + {% endfor %} +
+
+
+ + + {% elif item.question.question_type == 'DATE' %} +
+
+ + Show all {{ item.date_answers|length }} date responses + +
+ {% for ans in item.date_answers %} + + {{ ans|date:"Y-m-d" }} + + {% empty %} +

No dates submitted yet.

+ {% endfor %} +
+
+
{% endif %}
diff --git a/survey/templates/survey/survey_take.html b/survey/templates/survey/survey_take.html index 3c75a35b..52fea52c 100644 --- a/survey/templates/survey/survey_take.html +++ b/survey/templates/survey/survey_take.html @@ -65,6 +65,12 @@ {% endif %}
+ {% if survey.before_text %} +
+ {{ survey.before_text|linebreaksbr }} +
+ {% endif %} + {% if form.errors %} {% endfor %} -
+
+ {% if mandatory == 'false' and skip_key %} + Skip Survey + {% endif %}
diff --git a/survey/templates/survey/survey_thanks.html b/survey/templates/survey/survey_thanks.html index 50024957..0c257ef1 100644 --- a/survey/templates/survey/survey_thanks.html +++ b/survey/templates/survey/survey_thanks.html @@ -10,7 +10,11 @@

Thank you!

-

Your feedback has been successfully submitted. We appreciate your response.

+ {% if after_text %} +

{{ after_text|linebreaksbr }}

+ {% else %} +

Your feedback has been successfully submitted. We appreciate your response.

+ {% endif %} Continue diff --git a/survey/tests/test_survey.py b/survey/tests/test_survey.py index 51543d5e..0b4f61ed 100644 --- a/survey/tests/test_survey.py +++ b/survey/tests/test_survey.py @@ -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 + + diff --git a/survey/urls.py b/survey/urls.py index 17241034..b084011c 100644 --- a/survey/urls.py +++ b/survey/urls.py @@ -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("/update/", views.survey_update, name="survey_update"), path("/delete/", views.survey_delete, name="survey_delete"), path("/questions/", views.survey_questions_edit, name="survey_questions_edit"), diff --git a/survey/views.py b/survey/views.py index bef653cb..2fdd80a1 100644 --- a/survey/views.py +++ b/survey/views.py @@ -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)