From 79ba4db4bf4025a6c2a49f86bf82b4cf955e9b8e Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 29 Jun 2026 09:04:28 +0100 Subject: [PATCH] add simple survey support --- .../0028_exam_post_survey_exam_pre_survey.py | 25 ++ atlas/forms.py | 2 + ...n_post_survey_casecollection_pre_survey.py | 25 ++ .../atlas/collection_take_overview.html | 12 + atlas/templates/atlas/help.html | 20 + atlas/views.py | 40 +- generic/forms.py | 6 +- generic/models.py | 17 + .../0036_exam_post_survey_exam_pre_survey.py | 25 ++ .../0019_exam_post_survey_exam_pre_survey.py | 25 ++ rad/settings.py | 1 + rad/urls.py | 1 + .../0018_exam_post_survey_exam_pre_survey.py | 25 ++ .../0024_exam_post_survey_exam_pre_survey.py | 25 ++ .../0012_exam_post_survey_exam_pre_survey.py | 25 ++ survey/__init__.py | 1 + survey/apps.py | 5 + survey/forms.py | 82 ++++ survey/migrations/0001_initial.py | 76 ++++ .../0002_survey_author_survey_authors_only.py | 25 ++ survey/migrations/__init__.py | 0 survey/models.py | 156 ++++++++ .../survey/survey_confirm_delete.html | 27 ++ survey/templates/survey/survey_form.html | 33 ++ survey/templates/survey/survey_list.html | 106 ++++++ .../survey/survey_questions_edit.html | 136 +++++++ survey/templates/survey/survey_results.html | 127 +++++++ survey/templates/survey/survey_take.html | 122 ++++++ survey/templates/survey/survey_thanks.html | 23 ++ survey/tests/test_survey.py | 120 ++++++ survey/urls.py | 15 + survey/views.py | 358 ++++++++++++++++++ templates/base.html | 3 + 33 files changed, 1687 insertions(+), 2 deletions(-) create mode 100644 anatomy/migrations/0028_exam_post_survey_exam_pre_survey.py create mode 100644 atlas/migrations/0109_casecollection_post_survey_casecollection_pre_survey.py create mode 100644 longs/migrations/0036_exam_post_survey_exam_pre_survey.py create mode 100644 physics/migrations/0019_exam_post_survey_exam_pre_survey.py create mode 100644 rapids/migrations/0018_exam_post_survey_exam_pre_survey.py create mode 100644 sbas/migrations/0024_exam_post_survey_exam_pre_survey.py create mode 100644 shorts/migrations/0012_exam_post_survey_exam_pre_survey.py create mode 100644 survey/__init__.py create mode 100644 survey/apps.py create mode 100644 survey/forms.py create mode 100644 survey/migrations/0001_initial.py create mode 100644 survey/migrations/0002_survey_author_survey_authors_only.py create mode 100644 survey/migrations/__init__.py create mode 100644 survey/models.py create mode 100644 survey/templates/survey/survey_confirm_delete.html create mode 100644 survey/templates/survey/survey_form.html create mode 100644 survey/templates/survey/survey_list.html create mode 100644 survey/templates/survey/survey_questions_edit.html create mode 100644 survey/templates/survey/survey_results.html create mode 100644 survey/templates/survey/survey_take.html create mode 100644 survey/templates/survey/survey_thanks.html create mode 100644 survey/tests/test_survey.py create mode 100644 survey/urls.py create mode 100644 survey/views.py diff --git a/anatomy/migrations/0028_exam_post_survey_exam_pre_survey.py b/anatomy/migrations/0028_exam_post_survey_exam_pre_survey.py new file mode 100644 index 00000000..347d710f --- /dev/null +++ b/anatomy/migrations/0028_exam_post_survey_exam_pre_survey.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-06-28 20:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0027_alter_anatomyquestion_primary_answer_and_more'), + ('survey', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='post_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'), + ), + migrations.AddField( + model_name='exam', + name='pre_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'), + ), + ] diff --git a/atlas/forms.py b/atlas/forms.py index cb52a0ca..83185669 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -361,6 +361,8 @@ class CaseCollectionForm(ModelForm): "question_time_limit", "answer_entry_grace_period", "prerequisites", + "pre_survey", + "post_survey", Fieldset( "Custom Start Screen", "start_screen_content", diff --git a/atlas/migrations/0109_casecollection_post_survey_casecollection_pre_survey.py b/atlas/migrations/0109_casecollection_post_survey_casecollection_pre_survey.py new file mode 100644 index 00000000..15b70f00 --- /dev/null +++ b/atlas/migrations/0109_casecollection_post_survey_casecollection_pre_survey.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-06-28 20:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0108_multiusersession'), + ('survey', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='casecollection', + name='post_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'), + ), + migrations.AddField( + model_name='casecollection', + name='pre_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'), + ), + ] diff --git a/atlas/templates/atlas/collection_take_overview.html b/atlas/templates/atlas/collection_take_overview.html index 9115b90f..890fdee0 100644 --- a/atlas/templates/atlas/collection_take_overview.html +++ b/atlas/templates/atlas/collection_take_overview.html @@ -51,6 +51,18 @@ + {% if post_survey_url %} + + {% endif %} +
{% for question, answer, self_review, review_stats, timing_state in question_answer_tuples %}
diff --git a/atlas/templates/atlas/help.html b/atlas/templates/atlas/help.html index ded45b3a..20e86ea9 100644 --- a/atlas/templates/atlas/help.html +++ b/atlas/templates/atlas/help.html @@ -75,5 +75,25 @@ +

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.

+ +
Managing Surveys (Staff/Admins)
+
    +
  • Create Surveys: Navigate to the Survey Dashboard to create a survey, define its name, description, and decide if responses should be anonymous.
  • +
  • Question Types: You can add questions of three types: +
      +
    • Free Text: For qualitative comments.
    • +
    • Multiple Choice: For single-select questions (entered one option per line).
    • +
    • Rating Scale: 1-to-5 Likert scale ratings.
    • +
    +
  • +
  • Question Reordering: Adjust question ordering using the numeric inputs and click Update Order.
  • +
  • Linking to Case Collections & Exams: Expose surveys by assigning them as Pre-take or Post-take surveys in the collection/exam edit screens. Candidates are automatically redirected to complete the Pre-take survey before they can begin, and are prompted to complete the Post-take survey once they finish.
  • +
  • Analytics & CSV Export: Click Analytics on the dashboard to view aggregated rating averages, response counts, graphical progress bar breakdowns, and free-text replies. You can export complete response details to CSV format by clicking Export CSV.
  • +
+ +
Taking Standalone/General Surveys
+

Surveys can also be distributed as standalone links (e.g. /survey/take/<id>/) for general platform feedback or course evaluation not tied to a specific exam.

{% endblock %} diff --git a/atlas/views.py b/atlas/views.py index 9562ffbf..ebac4a7a 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -112,6 +112,7 @@ from .forms import ( UserQuestionAnswerForm, UserReportAnswerForm, ) +from survey.views import check_survey_interception from generic.models import UserHiddenItem from .models import ( Case, @@ -6930,7 +6931,6 @@ def collection_take_start(request, pk, cid=None, passcode=None): _type_: _description_ """ collection = get_object_or_404(CaseCollection, pk=pk) - try: collection.check_user_can_take(cid, passcode, request.user) valid_user = True @@ -6947,6 +6947,11 @@ def collection_take_start(request, pk, cid=None, passcode=None): {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode}, ) + # Check if pre-survey is required and not yet taken + response = check_survey_interception(request, collection, cid, passcode) + if response: + return response + #valid_user = collection.check_logged_in_user(request) @@ -7729,6 +7734,32 @@ def collection_take_overview( else: question_answer_tuples.append((cd, None, self_review, review_stats, timing_state)) + post_survey_url = None + if collection.post_survey: + user_param = request.user if request.user.is_authenticated else None + cid_param = int(cid) if cid and str(cid).isdigit() else None + + from django.contrib.contenttypes.models import ContentType + from survey.models import SurveyResponse + ct = ContentType.objects.get_for_model(collection) + + has_responded = SurveyResponse.objects.filter( + survey=collection.post_survey, + user=user_param, + cid=cid_param, + content_type=ct, + object_id=collection.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": 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}" + return render( request, "atlas/collection_take_overview.html", @@ -7743,6 +7774,7 @@ def collection_take_overview( "outstanding_case_count": outstanding_case_count, "total_outstanding_feedback": total_outstanding_feedback, "end_overview_resources": collection.end_overview_resources.all(), + "post_survey_url": post_survey_url, }, ) @@ -8368,6 +8400,12 @@ def collection_case_view_take( """ collection = get_object_or_404(CaseCollection, pk=pk) + # Check if pre-survey is required and not yet taken + from survey.views import check_survey_interception + response = check_survey_interception(request, collection, cid, passcode) + if response: + return response + # If caller provided a case_id (case PK) instead of a case_number (index), # translate it into the index used by collection.get_case_by_index(). if case_id is not None and case_number is None: diff --git a/generic/forms.py b/generic/forms.py index c4e7e599..15c47976 100755 --- a/generic/forms.py +++ b/generic/forms.py @@ -146,6 +146,8 @@ class ExamFormMixin: "active", "publish_results", "archive", + "pre_survey", + "post_survey", Accordion( AccordionGroup("Date restrictions", "restrict_to_dates", @@ -214,7 +216,9 @@ class ExamFormMixin: "active", "publish_results", "archive", - "results_supervisor_visible" + "results_supervisor_visible", + "pre_survey", + "post_survey", # "cid_user_groups", # "user_user_groups", # "author", diff --git a/generic/models.py b/generic/models.py index a76db4be..1de0d07a 100644 --- a/generic/models.py +++ b/generic/models.py @@ -716,6 +716,23 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin): exam_open_access = models.BooleanField(default=False, help_text="Set to true if you want any registered user to be able to take the exam.") + pre_survey = models.ForeignKey( + "survey.Survey", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="%(app_label)s_%(class)s_pre_surveys", + help_text="Survey to be filled out BEFORE taking this collection/exam." + ) + post_survey = models.ForeignKey( + "survey.Survey", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="%(app_label)s_%(class)s_post_surveys", + help_text="Survey to be filled out AFTER completing this collection/exam." + ) + class Meta: abstract = True diff --git a/longs/migrations/0036_exam_post_survey_exam_pre_survey.py b/longs/migrations/0036_exam_post_survey_exam_pre_survey.py new file mode 100644 index 00000000..04c228d5 --- /dev/null +++ b/longs/migrations/0036_exam_post_survey_exam_pre_survey.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-06-28 20:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('longs', '0035_alter_longseriesimage_image_blake3_hash'), + ('survey', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='post_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'), + ), + migrations.AddField( + model_name='exam', + name='pre_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'), + ), + ] diff --git a/physics/migrations/0019_exam_post_survey_exam_pre_survey.py b/physics/migrations/0019_exam_post_survey_exam_pre_survey.py new file mode 100644 index 00000000..71d51cbc --- /dev/null +++ b/physics/migrations/0019_exam_post_survey_exam_pre_survey.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-06-28 20:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('physics', '0018_exam_created_exam_updated'), + ('survey', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='post_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'), + ), + migrations.AddField( + model_name='exam', + name='pre_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'), + ), + ] diff --git a/rad/settings.py b/rad/settings.py index 517d7e06..0e50451d 100644 --- a/rad/settings.py +++ b/rad/settings.py @@ -90,6 +90,7 @@ INSTALLED_APPS = [ 'django_psutil_dash', 'django_tasks', 'django_tasks_db', + 'survey', ] MIDDLEWARE = [ diff --git a/rad/urls.py b/rad/urls.py index 8e8c1845..b0f2e2ff 100644 --- a/rad/urls.py +++ b/rad/urls.py @@ -50,6 +50,7 @@ urlpatterns = [ path("sbas/", include("sbas.urls"), name="sbas"), path("generic/", include("generic.urls"), name="generic"), path("atlas/", include("atlas.urls"), name="atlas"), + path("survey/", include("survey.urls")), path("research/", include("research.urls"), name="research"), path("rcr/", include("rcr.urls"), name="rcr"), path("rota/", include("rota.urls"), name="rota"), diff --git a/rapids/migrations/0018_exam_post_survey_exam_pre_survey.py b/rapids/migrations/0018_exam_post_survey_exam_pre_survey.py new file mode 100644 index 00000000..327aac0d --- /dev/null +++ b/rapids/migrations/0018_exam_post_survey_exam_pre_survey.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-06-28 20:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rapids', '0017_exam_created_exam_updated'), + ('survey', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='post_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'), + ), + migrations.AddField( + model_name='exam', + name='pre_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'), + ), + ] diff --git a/sbas/migrations/0024_exam_post_survey_exam_pre_survey.py b/sbas/migrations/0024_exam_post_survey_exam_pre_survey.py new file mode 100644 index 00000000..dd54f79f --- /dev/null +++ b/sbas/migrations/0024_exam_post_survey_exam_pre_survey.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-06-28 20:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sbas', '0023_exam_created_exam_updated'), + ('survey', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='post_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'), + ), + migrations.AddField( + model_name='exam', + name='pre_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'), + ), + ] diff --git a/shorts/migrations/0012_exam_post_survey_exam_pre_survey.py b/shorts/migrations/0012_exam_post_survey_exam_pre_survey.py new file mode 100644 index 00000000..957cc94f --- /dev/null +++ b/shorts/migrations/0012_exam_post_survey_exam_pre_survey.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-06-28 20:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0011_exam_created_exam_updated'), + ('survey', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='post_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'), + ), + migrations.AddField( + model_name='exam', + name='pre_survey', + field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'), + ), + ] diff --git a/survey/__init__.py b/survey/__init__.py new file mode 100644 index 00000000..e1cc939f --- /dev/null +++ b/survey/__init__.py @@ -0,0 +1 @@ +# Survey app package diff --git a/survey/apps.py b/survey/apps.py new file mode 100644 index 00000000..e3a9258e --- /dev/null +++ b/survey/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class SurveyConfig(AppConfig): + default_auto_field = 'django.db.models.AutoField' + name = 'survey' diff --git a/survey/forms.py b/survey/forms.py new file mode 100644 index 00000000..cb7963f0 --- /dev/null +++ b/survey/forms.py @@ -0,0 +1,82 @@ +from django import forms +from django.forms import ModelMultipleChoiceField +from django.contrib.auth.models import User +from generic.widgets import UserSearchWidget +from .models import Survey, SurveyQuestion, SurveyResponse, SurveyAnswer + +class SurveyForm(forms.ModelForm): + author = ModelMultipleChoiceField( + queryset=User.objects.all(), + widget=UserSearchWidget(), + required=False, + help_text="Users who can view and manage this survey when it is set to private." + ) + + class Meta: + model = Survey + fields = ["name", "description", "active", "anonymous", "authors_only", "author"] + +class SurveyQuestionForm(forms.ModelForm): + class Meta: + model = SurveyQuestion + fields = ["text", "question_type", "choices", "required", "position"] + widgets = { + "choices": forms.Textarea(attrs={"rows": 4, "placeholder": "Enter each choice on a new line"}), + } + +class SurveyTakeForm(forms.Form): + def __init__(self, *args, **kwargs): + self.survey = kwargs.pop("survey") + self.questions = list(self.survey.questions.all()) + super().__init__(*args, **kwargs) + + for q in self.questions: + field_name = f"question_{q.pk}" + if q.question_type == SurveyQuestion.QuestionType.TEXT: + self.fields[field_name] = forms.CharField( + label=q.text, + required=q.required, + widget=forms.Textarea(attrs={"rows": 3, "class": "form-control"}) + ) + elif q.question_type == SurveyQuestion.QuestionType.CHOICE: + choices = [(c, c) for c in q.get_choices_list()] + self.fields[field_name] = forms.ChoiceField( + label=q.text, + required=q.required, + choices=[("", "Select an option")] + choices, + widget=forms.Select(attrs={"class": "form-select"}) + ) + elif q.question_type == SurveyQuestion.QuestionType.RATING: + self.fields[field_name] = forms.ChoiceField( + label=q.text, + required=q.required, + choices=[(str(i), str(i)) for i in range(1, 6)], + widget=forms.RadioSelect(attrs={"class": "form-check-input"}), + ) + + 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 + response_cid = None if self.survey.anonymous else cid + + response = SurveyResponse.objects.create( + survey=self.survey, + user=response_user, + cid=response_cid, + content_type=content_type, + object_id=object_id, + pre_or_post=pre_or_post + ) + + for q in self.questions: + field_name = f"question_{q.pk}" + val = self.cleaned_data.get(field_name) + if val is not None and val != "": + ans = SurveyAnswer(response=response, question=q) + if q.question_type == SurveyQuestion.QuestionType.TEXT: + ans.text_answer = val + elif q.question_type == SurveyQuestion.QuestionType.CHOICE: + ans.choice_answer = val + elif q.question_type == SurveyQuestion.QuestionType.RATING: + ans.rating_answer = int(val) + ans.save() + return response diff --git a/survey/migrations/0001_initial.py b/survey/migrations/0001_initial.py new file mode 100644 index 00000000..3d8fc88d --- /dev/null +++ b/survey/migrations/0001_initial.py @@ -0,0 +1,76 @@ +# Generated by Django 6.0.1 on 2026-06-28 20:59 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Survey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Title of the survey', max_length=200)), + ('description', models.TextField(blank=True, help_text='Introductory text or description')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('active', models.BooleanField(default=True, help_text='If inactive, users will not be able to fill out the survey.')), + ('anonymous', models.BooleanField(default=False, help_text='If checked, responses will not link to the user account or candidate details.')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_surveys', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SurveyQuestion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(help_text='The question text to display to users')), + ('question_type', models.CharField(choices=[('TEXT', 'Free Text'), ('CHOICE', 'Multiple Choice (Single Option)'), ('RATING', 'Rating (1-5 Likert scale)')], default='TEXT', max_length=20)), + ('choices', models.TextField(blank=True, help_text='For Multiple Choice questions, enter options separated by a new line.')), + ('required', models.BooleanField(default=True, help_text='If checked, users must answer this question to submit.')), + ('position', models.IntegerField(default=0, help_text='Ordering position of the question.')), + ('survey', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='survey.survey')), + ], + options={ + 'ordering': ['position', 'id'], + }, + ), + migrations.CreateModel( + name='SurveyResponse', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cid', models.IntegerField(blank=True, help_text='Candidate ID if taken by an anonymous candidate login.', null=True)), + ('object_id', models.PositiveIntegerField(blank=True, null=True)), + ('pre_or_post', models.CharField(choices=[('PRE', 'Pre-take survey'), ('POST', 'Post-take survey'), ('GENERAL', 'General feedback')], default='GENERAL', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype')), + ('survey', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='survey.survey')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='survey_responses', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SurveyAnswer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text_answer', models.TextField(blank=True, null=True)), + ('choice_answer', models.CharField(blank=True, max_length=255, null=True)), + ('rating_answer', models.IntegerField(blank=True, null=True)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='survey.surveyquestion')), + ('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='survey.surveyresponse')), + ], + ), + ] diff --git a/survey/migrations/0002_survey_author_survey_authors_only.py b/survey/migrations/0002_survey_author_survey_authors_only.py new file mode 100644 index 00000000..930655d4 --- /dev/null +++ b/survey/migrations/0002_survey_author_survey_authors_only.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-06-28 21:23 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('survey', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='survey', + name='author', + field=models.ManyToManyField(blank=True, help_text='Author/Manager(s) of the survey', related_name='surveys', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='survey', + name='authors_only', + field=models.BooleanField(default=False, help_text='If true, only survey authors will be able to view or manage this survey.'), + ), + ] diff --git a/survey/migrations/__init__.py b/survey/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/survey/models.py b/survey/models.py new file mode 100644 index 00000000..e8b57dab --- /dev/null +++ b/survey/models.py @@ -0,0 +1,156 @@ +from django.db import models +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey +from django.urls import reverse +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") + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_surveys" + ) + author = models.ManyToManyField( + User, + blank=True, + related_name="surveys", + help_text="Author/Manager(s) of the survey" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + active = models.BooleanField( + default=True, + help_text="If inactive, users will not be able to fill out the survey." + ) + anonymous = models.BooleanField( + default=False, + help_text="If checked, responses will not link to the user account or candidate details." + ) + authors_only = models.BooleanField( + default=False, + help_text="If true, only survey authors will be able to view or manage this survey." + ) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("survey:survey_detail", kwargs={"pk": self.pk}) + + +class SurveyQuestion(models.Model): + class QuestionType(models.TextChoices): + TEXT = "TEXT", "Free Text" + CHOICE = "CHOICE", "Multiple Choice (Single Option)" + RATING = "RATING", "Rating (1-5 Likert scale)" + + survey = models.ForeignKey( + Survey, + on_delete=models.CASCADE, + related_name="questions" + ) + text = models.TextField(help_text="The question text to display to users") + question_type = models.CharField( + max_length=20, + choices=QuestionType.choices, + default=QuestionType.TEXT + ) + choices = models.TextField( + blank=True, + help_text="For Multiple Choice questions, enter options separated by a new line." + ) + required = models.BooleanField( + default=True, + help_text="If checked, users must answer this question to submit." + ) + position = models.IntegerField( + default=0, + help_text="Ordering position of the question." + ) + + class Meta: + ordering = ["position", "id"] + + def __str__(self): + return f"{self.survey.name} - Question {self.position}: {self.text[:50]}" + + def get_choices_list(self): + if not self.choices: + return [] + return [c.strip() for c in self.choices.split("\n") if c.strip()] + + +class SurveyResponse(models.Model): + class SurveyContext(models.TextChoices): + PRE = "PRE", "Pre-take survey" + POST = "POST", "Post-take survey" + GENERAL = "GENERAL", "General feedback" + + survey = models.ForeignKey( + Survey, + on_delete=models.CASCADE, + related_name="responses" + ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="survey_responses" + ) + cid = models.IntegerField( + null=True, + blank=True, + help_text="Candidate ID if taken by an anonymous candidate login." + ) + + # Generic relationship to associate the response with a CaseCollection or Exam + content_type = models.ForeignKey( + ContentType, + on_delete=models.SET_NULL, + null=True, + blank=True + ) + object_id = models.PositiveIntegerField(null=True, blank=True) + content_object = GenericForeignKey("content_type", "object_id") + + pre_or_post = models.CharField( + max_length=10, + choices=SurveyContext.choices, + default=SurveyContext.GENERAL + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + user_str = self.user.username if self.user else (f"CID {self.cid}" if self.cid else "Anonymous") + return f"Response for {self.survey.name} by {user_str} ({self.pre_or_post})" + + +class SurveyAnswer(models.Model): + response = models.ForeignKey( + SurveyResponse, + on_delete=models.CASCADE, + related_name="answers" + ) + question = models.ForeignKey( + SurveyQuestion, + on_delete=models.CASCADE, + related_name="answers" + ) + 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) + + def __str__(self): + return f"Answer to {self.question.id} for response {self.response.id}" diff --git a/survey/templates/survey/survey_confirm_delete.html b/survey/templates/survey/survey_confirm_delete.html new file mode 100644 index 00000000..30a8dd5a --- /dev/null +++ b/survey/templates/survey/survey_confirm_delete.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+
+

Delete Survey

+
+
+

Are you sure you want to delete the survey "{{ survey.name }}"?

+

This action will permanently delete all associated questions, responses, and answers.

+ +
+ {% csrf_token %} +
+ Cancel + +
+
+
+
+
+
+
+{% endblock %} diff --git a/survey/templates/survey/survey_form.html b/survey/templates/survey/survey_form.html new file mode 100644 index 00000000..8750487d --- /dev/null +++ b/survey/templates/survey/survey_form.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+
+
+

+ {% if survey %}Edit Survey details{% else %}Create New Survey{% endif %} +

+
+
+
+ {% csrf_token %} + {{ form|crispy }} + +
+ + Cancel + + +
+
+
+
+
+
+
+{% endblock %} diff --git a/survey/templates/survey/survey_list.html b/survey/templates/survey/survey_list.html new file mode 100644 index 00000000..72d827f7 --- /dev/null +++ b/survey/templates/survey/survey_list.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+
+
+

Survey Dashboard

+

Create and manage reusable feedback forms and post-exam surveys.

+
+ +
+ + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+
+
+ + + + + + + + + + + + + + {% for survey in surveys %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
Survey NameCreated ByQuestionsResponsesStatusTypeActions
+ {{ survey.name }} + {% if survey.description %} + {{ survey.description }} + {% endif %} + {{ survey.created_by.username|default:"System" }} + {{ survey.questions.count }} + + {{ survey.responses.count }} + + {% if survey.active %} + Active + {% else %} + Inactive + {% endif %} + + {% if survey.anonymous %} + Anonymous + {% else %} + Standard + {% endif %} + + +
+ + No surveys created yet. Click "Create Survey" to get started. +
+
+
+
+
+{% endblock %} diff --git a/survey/templates/survey/survey_questions_edit.html b/survey/templates/survey/survey_questions_edit.html new file mode 100644 index 00000000..8bb7acfb --- /dev/null +++ b/survey/templates/survey/survey_questions_edit.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+ + Back to Dashboard + +

Questions: {{ survey.name }}

+

Add, reorder, or remove questions in this survey.

+
+
+ + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ +
+
+
+

Current Questions

+
+
+ {% if questions %} +
+ {% csrf_token %} + +
+ {% for q in questions %} +
+
+
+ +
+
+ {{ q.text }} + + Type: {{ q.get_question_type_display }} + {% if q.required %}*Required{% else %}(Optional){% endif %} + + {% if q.question_type == 'CHOICE' %} +
+ Options: {{ q.get_choices_list|join:", " }} +
+ {% endif %} +
+
+ +
+
+
+ {% endfor %} +
+ +
+ {% else %} +

No questions added yet. Use the form on the right to add your first question.

+ {% endif %} +
+
+
+ + +
+
+
+

Add Question

+
+
+
+ {% csrf_token %} + + + {{ form|crispy }} + +
+ +
+
+
+
+
+
+
+ + + + + +{% endblock %} diff --git a/survey/templates/survey/survey_results.html b/survey/templates/survey/survey_results.html new file mode 100644 index 00000000..ee2bfde5 --- /dev/null +++ b/survey/templates/survey/survey_results.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ + Back to Dashboard + +

Results: {{ survey.name }}

+

Analytics and details for the feedback questions.

+
+ +
+ +
+
+
+
+
Total Submissions
+ {{ responses_count }} +
+
+
+
+
+
+
Total Questions
+ {{ survey.questions.count }} +
+
+
+
+
+
+
Anonymous Mode
+ {% if survey.anonymous %}Yes{% else %}No{% endif %} +
+
+
+
+ +

Question Breakdowns

+ +
+ {% for item in analytics %} +
+
+
+

+ {{ forloop.counter }} + {{ item.question.text }} +

+ {{ item.question.get_question_type_display }} +
+
+

Answers: {{ item.answers_count }} / {{ responses_count }} submissions

+ + + {% if item.question.question_type == 'RATING' %} +
+
+
Average Score
+
{{ item.average_rating }}
+
Out of 5
+
+
+ {% for r in item.rating_breakdown %} +
+ {{ r.rating }} ★ +
+
+
+ {{ r.count }} ({{ r.percentage }}%) +
+ {% endfor %} +
+
+ + + {% elif item.question.question_type == 'CHOICE' %} +
+ {% for c in item.choice_counts %} +
+
+ {{ c.choice }} + {{ c.count }} responses ({{ c.percentage }}%) +
+
+
+
+
+ {% empty %} +

No choice options selected yet.

+ {% endfor %} +
+ + + {% elif item.question.question_type == 'TEXT' %} +
+
+ + Show all {{ item.text_answers|length }} replies + +
+ {% for ans in item.text_answers %} +
+

{{ ans|linebreaksbr }}

+
+ {% empty %} +

No text responses submitted yet.

+ {% endfor %} +
+
+
+ {% endif %} +
+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/survey/templates/survey/survey_take.html b/survey/templates/survey/survey_take.html new file mode 100644 index 00000000..3c75a35b --- /dev/null +++ b/survey/templates/survey/survey_take.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} + +{% block content %} + + +
+
+
+
+
+

{{ survey.name }}

+ {% if survey.description %} +

{{ survey.description|linebreaksbr }}

+ {% endif %} + {% if pre_or_post == 'PRE' %} +
Pre-exam survey
+ {% elif pre_or_post == 'POST' %} +
Post-exam survey
+ {% endif %} +
+
+ {% if form.errors %} + + {% endif %} + +
+ {% csrf_token %} + + + + + + + + + {% for field in form %} +
+ + +
+ {% if field.field.widget.input_type == 'radio' %} +
+ {{ field }} +
+ Poor + Excellent +
+
+ {% else %} + {{ field }} + {% endif %} +
+ + {% if field.errors %} +
+ {{ field.errors }} +
+ {% endif %} +
+ {% endfor %} + +
+ +
+
+
+
+
+
+
+{% endblock %} diff --git a/survey/templates/survey/survey_thanks.html b/survey/templates/survey/survey_thanks.html new file mode 100644 index 00000000..50024957 --- /dev/null +++ b/survey/templates/survey/survey_thanks.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+
+
+ +
+

Thank you!

+

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

+ + + Continue + +
+
+
+
+
+{% endblock %} diff --git a/survey/tests/test_survey.py b/survey/tests/test_survey.py new file mode 100644 index 00000000..51543d5e --- /dev/null +++ b/survey/tests/test_survey.py @@ -0,0 +1,120 @@ +import pytest +from django.urls import reverse +from django.contrib.contenttypes.models import ContentType +from survey.models import Survey, SurveyQuestion, SurveyResponse, SurveyAnswer +from survey.views import check_survey_interception +from atlas.models import CaseCollection + +@pytest.mark.django_db +def test_survey_creation(): + survey = Survey.objects.create(name="Test Course Survey", description="Feedback on course") + assert survey.name == "Test Course Survey" + assert survey.active is True + assert survey.anonymous is False + +@pytest.mark.django_db +def test_survey_questions(): + survey = Survey.objects.create(name="Test Survey") + q1 = SurveyQuestion.objects.create( + survey=survey, + text="Rate this case", + question_type=SurveyQuestion.QuestionType.RATING, + position=10 + ) + q2 = SurveyQuestion.objects.create( + survey=survey, + text="Any comments?", + question_type=SurveyQuestion.QuestionType.TEXT, + position=20 + ) + assert survey.questions.count() == 2 + assert q1.get_choices_list() == [] + +@pytest.mark.django_db +def test_survey_response_and_answers(admin_user): + survey = Survey.objects.create(name="Test Survey") + q1 = SurveyQuestion.objects.create( + survey=survey, text="Rate this case", question_type=SurveyQuestion.QuestionType.RATING + ) + + # Simulate a survey response save + response = SurveyResponse.objects.create( + survey=survey, + user=admin_user, + pre_or_post="PRE" + ) + answer = SurveyAnswer.objects.create( + response=response, + question=q1, + rating_answer=4 + ) + + assert response.answers.count() == 1 + assert answer.rating_answer == 4 + +@pytest.mark.django_db +def test_interception_helper(rf, admin_user): + survey = Survey.objects.create(name="Pre-take survey") + collection = CaseCollection.objects.create(name="Test Collection", pre_survey=survey) + + request = rf.get('/atlas/collection/1/take/') + request.user = admin_user + + # 1. First check: should redirect to pre-survey since they haven't filled it out + redirect_response = check_survey_interception(request, collection) + assert redirect_response is not None + assert "take/" in redirect_response.url + + # 2. Add response and check again: should not redirect + ct = ContentType.objects.get_for_model(collection) + resp = SurveyResponse.objects.create( + survey=survey, + user=admin_user, + content_type=ct, + object_id=collection.pk, + pre_or_post="PRE" + ) + + no_redirect_response = check_survey_interception(request, collection) + assert no_redirect_response is None + +@pytest.mark.django_db +def test_survey_authorship(admin_user): + survey = Survey.objects.create(name="Author Test Survey") + survey.add_author(admin_user) + assert survey.is_author(admin_user) is True + assert admin_user in survey.get_author_objects() + assert survey.get_authors() == admin_user.username + +@pytest.mark.django_db +def test_can_manage_survey_permission(admin_user, django_user_model): + from survey.views import can_manage_survey + from django.contrib.auth.models import Group + + survey = Survey.objects.create(name="Permissions Test Survey", authors_only=True) + + # 1. Non-staff user should not have permission + regular_user = django_user_model.objects.create_user(username="trainee", password="pw") + assert can_manage_survey(regular_user, survey) is False + + # 2. Staff user in cid_user_manager but not author should not have permission for private survey + manager_group, _ = Group.objects.get_or_create(name="cid_user_manager") + manager_user = django_user_model.objects.create_user(username="manager", password="pw") + manager_user.groups.add(manager_group) + assert can_manage_survey(manager_user, survey) is False + + # 3. Superuser should always have permission + superuser = django_user_model.objects.create_superuser(username="admin2", password="pw") + assert can_manage_survey(superuser, survey) is True + + # 4. Author should have permission even if private + survey.add_author(manager_user) + assert can_manage_survey(manager_user, survey) is True + + # 5. Non-author manager should have permission if survey is public + survey.authors_only = False + survey.save() + other_manager = django_user_model.objects.create_user(username="other_manager", password="pw") + other_manager.groups.add(manager_group) + assert can_manage_survey(other_manager, survey) is True + diff --git a/survey/urls.py b/survey/urls.py new file mode 100644 index 00000000..17241034 --- /dev/null +++ b/survey/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from . import views + +app_name = "survey" + +urlpatterns = [ + path("", views.survey_list, name="survey_list"), + path("create/", views.survey_create, name="survey_create"), + 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"), + path("/take/", views.survey_take, name="survey_take"), + path("/results/", views.survey_results, name="survey_results"), + path("/export/", views.survey_export_csv, name="survey_export_csv"), +] diff --git a/survey/views.py b/survey/views.py new file mode 100644 index 00000000..bef653cb --- /dev/null +++ b/survey/views.py @@ -0,0 +1,358 @@ +import csv +from django.shortcuts import render, get_object_or_404, redirect +from django.http import HttpResponse, JsonResponse, Http404 +from django.contrib.auth.decorators import login_required, user_passes_test +from django.db.models import Avg, Count +from django.contrib import messages +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied + +from generic.models import CidUser +from .models import Survey, SurveyQuestion, SurveyResponse, SurveyAnswer +from .forms import SurveyForm, SurveyQuestionForm, SurveyTakeForm + +def is_survey_admin(user): + """Checks if the user has survey administrative permissions.""" + return user.is_authenticated and (user.is_superuser or user.groups.filter(name="cid_user_manager").exists()) + +def can_manage_survey(user, survey): + """Checks if a user is authorized to manage/view administrative actions on a survey.""" + if user.is_superuser: + return True + if not user.is_authenticated or not user.groups.filter(name="cid_user_manager").exists(): + return False + if survey.authors_only: + return survey.is_author(user) + return True + +@login_required +@user_passes_test(is_survey_admin) +def survey_list(request): + """ + Scope: Staff/Admin. + Functionality: Displays the dashboard list of all feedback surveys. + """ + if request.user.is_superuser: + surveys = Survey.objects.all() + else: + from django.db.models import Q + surveys = Survey.objects.filter( + Q(authors_only=False) | Q(author=request.user) + ).distinct() + return render(request, "survey/survey_list.html", {"surveys": surveys}) + +@login_required +@user_passes_test(is_survey_admin) +def survey_create(request): + """ + Scope: Staff/Admin. + Functionality: Form to create a new survey. + """ + if request.method == "POST": + form = SurveyForm(request.POST) + if form.is_valid(): + survey = form.save(commit=False) + survey.created_by = request.user + survey.save() + form.save_m2m() + survey.add_author(request.user) + messages.success(request, f"Survey '{survey.name}' created successfully.") + return redirect("survey:survey_questions_edit", pk=survey.pk) + else: + form = SurveyForm() + return render(request, "survey/survey_form.html", {"form": form, "action": "Create"}) + +@login_required +@user_passes_test(is_survey_admin) +def survey_update(request, pk): + """ + Scope: Staff/Admin. + Functionality: Form to edit an existing survey's metadata. + """ + 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.") + if request.method == "POST": + form = SurveyForm(request.POST, instance=survey) + if form.is_valid(): + form.save() + messages.success(request, f"Survey '{survey.name}' updated.") + return redirect("survey:survey_list") + else: + form = SurveyForm(instance=survey) + return render(request, "survey/survey_form.html", {"form": form, "action": "Update", "survey": survey}) + +@login_required +@user_passes_test(is_survey_admin) +def survey_delete(request, pk): + """ + Scope: Staff/Admin. + Functionality: Deletes a survey. + """ + 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.") + if request.method == "POST": + survey.delete() + messages.success(request, "Survey deleted successfully.") + return redirect("survey:survey_list") + return render(request, "survey/survey_confirm_delete.html", {"survey": survey}) + +@login_required +@user_passes_test(is_survey_admin) +def survey_questions_edit(request, pk): + """ + Scope: Staff/Admin. + Functionality: Question manager. Allows adding, deleting, and reordering questions. + """ + 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 = survey.questions.all() + + if request.method == "POST": + action = request.POST.get("action") + if action == "add": + form = SurveyQuestionForm(request.POST) + if form.is_valid(): + q = form.save(commit=False) + q.survey = survey + # set position to end if not specified + if not q.position: + q.position = (questions.count() + 1) * 10 + q.save() + messages.success(request, "Question added.") + return redirect("survey:survey_questions_edit", pk=survey.pk) + elif action == "delete": + q_id = request.POST.get("question_id") + q = get_object_or_404(SurveyQuestion, pk=q_id, survey=survey) + q.delete() + messages.success(request, "Question deleted.") + return redirect("survey:survey_questions_edit", pk=survey.pk) + elif action == "reorder": + for key, val in request.POST.items(): + if key.startswith("order_") and val.isdigit(): + q_pk = key.split("_")[1] + SurveyQuestion.objects.filter(pk=q_pk, survey=survey).update(position=int(val)) + messages.success(request, "Question order updated.") + return redirect("survey:survey_questions_edit", pk=survey.pk) + else: + form = SurveyQuestionForm() + + return render(request, "survey/survey_questions_edit.html", { + "survey": survey, + "questions": questions, + "form": form + }) + +def survey_take(request, pk): + """ + Scope: Candidates/Users. + Functionality: Renders survey questions and processes dynamic form submission. + Supports standard login, CID candidate login, and standalone modes. + """ + survey = get_object_or_404(Survey, pk=pk, active=True) + + # Retrieve context from GET or POST + pre_or_post = request.GET.get("pre_or_post") or request.POST.get("pre_or_post") or "GENERAL" + content_type_id = request.GET.get("content_type_id") or request.POST.get("content_type_id") + 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 "/" + + # Handle CID auth + cid = request.GET.get("cid") or request.POST.get("cid") + passcode = request.GET.get("passcode") or request.POST.get("passcode") + cid_user = None + + if cid and passcode: + cid_user = CidUser.objects.filter(cid=cid).first() + if not cid_user or cid_user.passcode != passcode: + raise Http404("Candidate ID / Passcode combination not found") + elif not request.user.is_authenticated and not survey.anonymous: + # If not anonymous and no CID, require login + return redirect(f"/accounts/login/?next={request.path}") + + content_type = None + if content_type_id: + try: + content_type = ContentType.objects.get_for_id(int(content_type_id)) + except (ValueError, ContentType.DoesNotExist): + pass + + if request.method == "POST": + form = SurveyTakeForm(request.POST, survey=survey) + if form.is_valid(): + form.save( + user=request.user if request.user.is_authenticated else None, + cid=int(cid) if cid and str(cid).isdigit() else None, + content_type=content_type, + 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}) + else: + form = SurveyTakeForm(survey=survey) + + return render(request, "survey/survey_take.html", { + "survey": survey, + "form": form, + "cid": cid, + "passcode": passcode, + "pre_or_post": pre_or_post, + "content_type_id": content_type_id, + "object_id": object_id, + "next": next_url, + }) + +@login_required +@user_passes_test(is_survey_admin) +def survey_results(request, pk): + """ + Scope: Staff/Admin. + Functionality: Renders survey response metrics, analytics, and text answers. + """ + 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() + + analytics = [] + for q in questions: + q_data = { + "question": q, + "answers_count": q.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="") + + elif q.question_type == SurveyQuestion.QuestionType.CHOICE: + # Count choices + choice_counts = q.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"] + if ans_text: + pct = round((cc["count"] / q_data["answers_count"]) * 100, 1) if q_data["answers_count"] else 0 + q_data["choice_counts"].append({ + "choice": ans_text, + "count": cc["count"], + "percentage": pct + }) + + elif q.question_type == SurveyQuestion.QuestionType.RATING: + # Stats for rating + avg = q.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")) + counts_dict = {i: 0 for i in range(1, 6)} + for rc in rating_counts: + r = rc["rating_answer"] + if r in counts_dict: + counts_dict[r] = rc["count"] + + q_data["rating_breakdown"] = [] + for r in range(1, 6): + count = counts_dict[r] + pct = round((count / q_data["answers_count"]) * 100, 1) if q_data["answers_count"] else 0 + q_data["rating_breakdown"].append({ + "rating": r, + "count": count, + "percentage": pct + }) + + analytics.append(q_data) + + return render(request, "survey/survey_results.html", { + "survey": survey, + "responses_count": responses_count, + "analytics": analytics + }) + +@login_required +@user_passes_test(is_survey_admin) +def survey_export_csv(request, pk): + """ + Scope: Staff/Admin. + Functionality: Exports all 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")) + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="survey_{survey.pk}_responses.csv"' + + writer = csv.writer(response) + + # Header row + headers = ["Response ID", "Timestamp", "User", "CID", "Context Type", "Context ID", "Pre/Post"] + for q in questions: + headers.append(f"Q: {q.text}") + writer.writerow(headers) + + # Data rows + for resp in survey.responses.all(): + 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 "" + object_id_str = str(resp.object_id) if resp.object_id else "" + + row = [ + resp.pk, + resp.created_at.strftime("%Y-%m-%d %H:%M:%S"), + user_str, + cid_str, + ct_str, + object_id_str, + resp.pre_or_post + ] + + # Get answers map + answers_map = {ans.question_id: ans for ans in resp.answers.all()} + for q in questions: + ans = answers_map.get(q.pk) + if not ans: + row.append("") + elif q.question_type == SurveyQuestion.QuestionType.TEXT: + row.append(ans.text_answer or "") + elif q.question_type == SurveyQuestion.QuestionType.CHOICE: + 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 "") + + writer.writerow(row) + + return response + +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. + """ + if exam_or_collection.pre_survey: + 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) + + has_responded = SurveyResponse.objects.filter( + survey=exam_or_collection.pre_survey, + user=user_param, + cid=cid_param, + content_type=ct, + object_id=exam_or_collection.pk, + pre_or_post=SurveyResponse.SurveyContext.PRE + ).exists() + + 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()}" + if cid and passcode: + params += f"&cid={cid}&passcode={passcode}" + return redirect(f"{survey_url}{params}") + return None diff --git a/templates/base.html b/templates/base.html index 48617f96..63ad545e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -341,6 +341,9 @@ {% endif %} {% if request.user.is_superuser or request.user|has_group:"cid_user_manager" %} +
  • + Feedback/Surveys +
  • Unused media cleanup