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