add simple survey support
This commit is contained in:
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -361,6 +361,8 @@ class CaseCollectionForm(ModelForm):
|
|||||||
"question_time_limit",
|
"question_time_limit",
|
||||||
"answer_entry_grace_period",
|
"answer_entry_grace_period",
|
||||||
"prerequisites",
|
"prerequisites",
|
||||||
|
"pre_survey",
|
||||||
|
"post_survey",
|
||||||
Fieldset(
|
Fieldset(
|
||||||
"Custom Start Screen",
|
"Custom Start Screen",
|
||||||
"start_screen_content",
|
"start_screen_content",
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -51,6 +51,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if post_survey_url %}
|
||||||
|
<div class="alert alert-info border-info bg-dark bg-opacity-25 my-3 d-flex align-items-center justify-content-between" role="alert">
|
||||||
|
<div>
|
||||||
|
<h5 class="alert-heading text-info mb-1"><i class="bi bi-patch-question me-2"></i>Post-Collection Feedback</h5>
|
||||||
|
<p class="mb-0 small text-muted">Please take a moment to complete the feedback survey for this case collection.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ post_survey_url }}" class="btn btn-info btn-sm text-dark fw-semibold px-4">
|
||||||
|
Take Survey <i class="bi bi-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="list-group mb-3">
|
<div class="list-group mb-3">
|
||||||
{% for question, answer, self_review, review_stats, timing_state in question_answer_tuples %}
|
{% for question, answer, self_review, review_stats, timing_state in question_answer_tuples %}
|
||||||
<div class="list-group-item {% if review_stats.has_outstanding_feedback %}border-warning border-2{% elif not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
|
<div class="list-group-item {% if review_stats.has_outstanding_feedback %}border-warning border-2{% elif not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
|
||||||
|
|||||||
@@ -75,5 +75,25 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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:
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h5>Taking Standalone/General Surveys</h5>
|
||||||
|
<p>Surveys can also be distributed as standalone links (e.g. <code>/survey/take/<id>/</code>) for general platform feedback or course evaluation not tied to a specific exam.</p>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+39
-1
@@ -112,6 +112,7 @@ from .forms import (
|
|||||||
UserQuestionAnswerForm,
|
UserQuestionAnswerForm,
|
||||||
UserReportAnswerForm,
|
UserReportAnswerForm,
|
||||||
)
|
)
|
||||||
|
from survey.views import check_survey_interception
|
||||||
from generic.models import UserHiddenItem
|
from generic.models import UserHiddenItem
|
||||||
from .models import (
|
from .models import (
|
||||||
Case,
|
Case,
|
||||||
@@ -6930,7 +6931,6 @@ def collection_take_start(request, pk, cid=None, passcode=None):
|
|||||||
_type_: _description_
|
_type_: _description_
|
||||||
"""
|
"""
|
||||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
collection.check_user_can_take(cid, passcode, request.user)
|
collection.check_user_can_take(cid, passcode, request.user)
|
||||||
valid_user = True
|
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},
|
{'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)
|
#valid_user = collection.check_logged_in_user(request)
|
||||||
|
|
||||||
@@ -7729,6 +7734,32 @@ def collection_take_overview(
|
|||||||
else:
|
else:
|
||||||
question_answer_tuples.append((cd, None, self_review, review_stats, timing_state))
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/collection_take_overview.html",
|
"atlas/collection_take_overview.html",
|
||||||
@@ -7743,6 +7774,7 @@ def collection_take_overview(
|
|||||||
"outstanding_case_count": outstanding_case_count,
|
"outstanding_case_count": outstanding_case_count,
|
||||||
"total_outstanding_feedback": total_outstanding_feedback,
|
"total_outstanding_feedback": total_outstanding_feedback,
|
||||||
"end_overview_resources": collection.end_overview_resources.all(),
|
"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)
|
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),
|
# 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().
|
# translate it into the index used by collection.get_case_by_index().
|
||||||
if case_id is not None and case_number is None:
|
if case_id is not None and case_number is None:
|
||||||
|
|||||||
+5
-1
@@ -146,6 +146,8 @@ class ExamFormMixin:
|
|||||||
"active",
|
"active",
|
||||||
"publish_results",
|
"publish_results",
|
||||||
"archive",
|
"archive",
|
||||||
|
"pre_survey",
|
||||||
|
"post_survey",
|
||||||
Accordion(
|
Accordion(
|
||||||
AccordionGroup("Date restrictions",
|
AccordionGroup("Date restrictions",
|
||||||
"restrict_to_dates",
|
"restrict_to_dates",
|
||||||
@@ -214,7 +216,9 @@ class ExamFormMixin:
|
|||||||
"active",
|
"active",
|
||||||
"publish_results",
|
"publish_results",
|
||||||
"archive",
|
"archive",
|
||||||
"results_supervisor_visible"
|
"results_supervisor_visible",
|
||||||
|
"pre_survey",
|
||||||
|
"post_survey",
|
||||||
# "cid_user_groups",
|
# "cid_user_groups",
|
||||||
# "user_user_groups",
|
# "user_user_groups",
|
||||||
# "author",
|
# "author",
|
||||||
|
|||||||
@@ -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.")
|
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:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -90,6 +90,7 @@ INSTALLED_APPS = [
|
|||||||
'django_psutil_dash',
|
'django_psutil_dash',
|
||||||
'django_tasks',
|
'django_tasks',
|
||||||
'django_tasks_db',
|
'django_tasks_db',
|
||||||
|
'survey',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ urlpatterns = [
|
|||||||
path("sbas/", include("sbas.urls"), name="sbas"),
|
path("sbas/", include("sbas.urls"), name="sbas"),
|
||||||
path("generic/", include("generic.urls"), name="generic"),
|
path("generic/", include("generic.urls"), name="generic"),
|
||||||
path("atlas/", include("atlas.urls"), name="atlas"),
|
path("atlas/", include("atlas.urls"), name="atlas"),
|
||||||
|
path("survey/", include("survey.urls")),
|
||||||
path("research/", include("research.urls"), name="research"),
|
path("research/", include("research.urls"), name="research"),
|
||||||
path("rcr/", include("rcr.urls"), name="rcr"),
|
path("rcr/", include("rcr.urls"), name="rcr"),
|
||||||
path("rota/", include("rota.urls"), name="rota"),
|
path("rota/", include("rota.urls"), name="rota"),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Survey app package
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class SurveyConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.AutoField'
|
||||||
|
name = 'survey'
|
||||||
@@ -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
|
||||||
@@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-danger bg-dark text-white shadow">
|
||||||
|
<div class="card-header bg-danger text-white py-3">
|
||||||
|
<h3 class="h5 mb-0"><i class="bi bi-exclamation-triangle me-2"></i>Delete Survey</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Are you sure you want to delete the survey <strong>"{{ survey.name }}"</strong>?</p>
|
||||||
|
<p class="text-danger small"><i class="bi bi-info-circle me-1"></i>This action will permanently delete all associated questions, responses, and answers.</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<a href="{% url 'survey:survey_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-danger">Confirm Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card bg-dark border-secondary text-white shadow">
|
||||||
|
<div class="card-header border-secondary py-3">
|
||||||
|
<h2 class="h4 card-title text-white mb-0">
|
||||||
|
{% if survey %}<i class="bi bi-pencil me-2 text-warning"></i>Edit Survey details{% else %}<i class="bi bi-plus-lg me-2 text-primary"></i>Create New Survey{% endif %}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<a href="{% url 'survey:survey_list' %}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Save Survey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 text-white"><i class="bi bi-patch-question me-2 text-primary"></i>Survey Dashboard</h1>
|
||||||
|
<p class="text-muted mb-0">Create and manage reusable feedback forms and post-exam surveys.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'survey:survey_create' %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Create Survey
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card bg-dark border-secondary text-white shadow">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-secondary">
|
||||||
|
<th class="ps-4 py-3">Survey Name</th>
|
||||||
|
<th class="py-3">Created By</th>
|
||||||
|
<th class="py-3 text-center">Questions</th>
|
||||||
|
<th class="py-3 text-center">Responses</th>
|
||||||
|
<th class="py-3 text-center">Status</th>
|
||||||
|
<th class="py-3 text-center">Type</th>
|
||||||
|
<th class="pe-4 py-3 text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for survey in surveys %}
|
||||||
|
<tr class="border-secondary">
|
||||||
|
<td class="ps-4 py-3">
|
||||||
|
<span class="fw-semibold text-white d-block">{{ survey.name }}</span>
|
||||||
|
{% if survey.description %}
|
||||||
|
<small class="text-muted d-block text-truncate" style="max-width: 250px;">{{ survey.description }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="py-3">{{ survey.created_by.username|default:"System" }}</td>
|
||||||
|
<td class="py-3 text-center">
|
||||||
|
<span class="badge bg-secondary">{{ survey.questions.count }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-center">
|
||||||
|
<span class="badge bg-info">{{ survey.responses.count }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-center">
|
||||||
|
{% if survey.active %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-center">
|
||||||
|
{% if survey.anonymous %}
|
||||||
|
<span class="badge bg-warning text-dark"><i class="bi bi-incognito me-1"></i>Anonymous</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary"><i class="bi bi-person me-1"></i>Standard</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="pe-4 py-3 text-end">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="{% url 'survey:survey_questions_edit' survey.pk %}" class="btn btn-outline-primary" title="Manage Questions">
|
||||||
|
<i class="bi bi-list-task me-1"></i>Questions
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'survey:survey_results' survey.pk %}" class="btn btn-outline-success" title="View Results">
|
||||||
|
<i class="bi bi-bar-chart-fill me-1"></i>Analytics
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'survey:survey_export_csv' survey.pk %}" class="btn btn-outline-info" title="Export CSV">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'survey:survey_update' survey.pk %}" class="btn btn-outline-secondary" title="Edit Metadata">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'survey:survey_delete' survey.pk %}" class="btn btn-outline-danger" title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-patch-question d-block fs-1 mb-3"></i>
|
||||||
|
No surveys created yet. Click "Create Survey" to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'survey:survey_list' %}" class="btn btn-sm btn-outline-secondary mb-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
<h1 class="h2 text-white"><i class="bi bi-list-task text-primary me-2"></i>Questions: {{ survey.name }}</h1>
|
||||||
|
<p class="text-muted mb-0">Add, reorder, or remove questions in this survey.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- Question list and reordering -->
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card bg-dark border-secondary text-white shadow h-100">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-3">
|
||||||
|
<h3 class="h5 card-title mb-0 text-white"><i class="bi bi-card-list me-2 text-primary"></i>Current Questions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if questions %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="reorder">
|
||||||
|
<div class="list-group list-group-flush mb-4">
|
||||||
|
{% for q in questions %}
|
||||||
|
<div class="list-group-item bg-dark border-secondary text-white py-3 px-0">
|
||||||
|
<div class="row align-items-center g-2">
|
||||||
|
<div class="col-auto">
|
||||||
|
<input type="number" name="order_{{ q.pk }}" value="{{ q.position }}" class="form-control form-control-sm text-center bg-black border-secondary text-white" style="width: 60px;" title="Order Position">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<span class="d-block fw-semibold">{{ q.text }}</span>
|
||||||
|
<small class="text-muted">
|
||||||
|
Type: <strong>{{ q.get_question_type_display }}</strong>
|
||||||
|
{% if q.required %}<span class="text-danger">*Required</span>{% else %}<span class="text-muted">(Optional)</span>{% endif %}
|
||||||
|
</small>
|
||||||
|
{% if q.question_type == 'CHOICE' %}
|
||||||
|
<div class="mt-2 small text-info">
|
||||||
|
<i class="bi bi-ui-checks-grid me-1"></i>Options: {{ q.get_choices_list|join:", " }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="confirmDeleteQuestion({{ q.pk }}, '{{ q.text|escapejs }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-success">
|
||||||
|
<i class="bi bi-save me-1"></i>Update Order
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center py-5">No questions added yet. Use the form on the right to add your first question.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add new question form -->
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card bg-dark border-secondary text-white shadow">
|
||||||
|
<div class="card-header border-secondary py-3">
|
||||||
|
<h3 class="h5 card-title mb-0 text-white"><i class="bi bi-plus-circle me-2 text-primary"></i>Add Question</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
|
||||||
|
{{ form|crispy }}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Add Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal Form -->
|
||||||
|
<form id="delete-question-form" method="post" style="display:none;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="question_id" id="delete-question-id">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function confirmDeleteQuestion(id, text) {
|
||||||
|
if (confirm("Are you sure you want to delete the question:\n\"" + text + "\"?")) {
|
||||||
|
document.getElementById('delete-question-id').value = id;
|
||||||
|
document.getElementById('delete-question-form').submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
var typeField = document.getElementById("id_question_type");
|
||||||
|
var choicesDiv = document.getElementById("div_id_choices");
|
||||||
|
|
||||||
|
function toggleChoices() {
|
||||||
|
if (typeField && choicesDiv) {
|
||||||
|
if (typeField.value === "CHOICE") {
|
||||||
|
choicesDiv.style.display = "block";
|
||||||
|
} else {
|
||||||
|
choicesDiv.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeField) {
|
||||||
|
typeField.addEventListener("change", toggleChoices);
|
||||||
|
toggleChoices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'survey:survey_list' %}" class="btn btn-sm btn-outline-secondary mb-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
<h1 class="h2 text-white"><i class="bi bi-bar-chart-line text-primary me-2"></i>Results: {{ survey.name }}</h1>
|
||||||
|
<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">
|
||||||
|
<i class="bi bi-download me-1"></i>Export to CSV
|
||||||
|
</a>
|
||||||
|
</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">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted uppercase mb-1">Total Submissions</h6>
|
||||||
|
<span class="display-4 fw-bold text-white">{{ responses_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card bg-dark border-secondary text-white text-center shadow p-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted uppercase mb-1">Total Questions</h6>
|
||||||
|
<span class="display-4 fw-bold text-white">{{ survey.questions.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card bg-dark border-secondary text-white text-center shadow p-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted uppercase mb-1">Anonymous Mode</h6>
|
||||||
|
<span class="display-4 fw-bold text-white">{% if survey.anonymous %}Yes{% else %}No{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="h4 text-white mb-3 mt-5"><i class="bi bi-pie-chart me-2"></i>Question Breakdowns</h2>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for item in analytics %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card bg-dark border-secondary text-white shadow">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h4 class="h5 mb-0 text-white">
|
||||||
|
<span class="badge bg-primary me-2">{{ forloop.counter }}</span>
|
||||||
|
{{ item.question.text }}
|
||||||
|
</h4>
|
||||||
|
<span class="badge bg-secondary">{{ item.question.get_question_type_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">Answers: <strong>{{ item.answers_count }}</strong> / {{ responses_count }} submissions</p>
|
||||||
|
|
||||||
|
<!-- RATING QUESTION -->
|
||||||
|
{% if item.question.question_type == 'RATING' %}
|
||||||
|
<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 Score</div>
|
||||||
|
<div class="display-3 fw-bold text-primary">{{ item.average_rating }}</div>
|
||||||
|
<div class="small text-muted">Out of 5</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 ps-md-4 py-3">
|
||||||
|
{% for r in item.rating_breakdown %}
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="me-2 text-white" style="width: 25px;">{{ r.rating }} ★</span>
|
||||||
|
<div class="progress flex-grow-1 bg-black border border-secondary" style="height: 12px;">
|
||||||
|
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ r.percentage }}%" aria-valuenow="{{ r.percentage }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<span class="ms-3 text-muted small" style="width: 90px; text-align: right;">{{ r.count }} ({{ r.percentage }}%)</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CHOICE QUESTION -->
|
||||||
|
{% elif item.question.question_type == 'CHOICE' %}
|
||||||
|
<div class="py-2">
|
||||||
|
{% for c in item.choice_counts %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="fw-semibold text-white">{{ c.choice }}</span>
|
||||||
|
<span class="text-muted small">{{ c.count }} responses ({{ c.percentage }}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress bg-black border border-secondary" style="height: 12px;">
|
||||||
|
<div class="progress-bar bg-info" role="progressbar" style="width: {{ c.percentage }}%" aria-valuenow="{{ c.percentage }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No choice options selected yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TEXT QUESTION -->
|
||||||
|
{% elif item.question.question_type == 'TEXT' %}
|
||||||
|
<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.text_answers|length }} replies
|
||||||
|
</summary>
|
||||||
|
<div class="mt-3" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
{% for ans in item.text_answers %}
|
||||||
|
<div class="p-2 border-bottom border-secondary mb-2 bg-dark rounded">
|
||||||
|
<p class="mb-0 text-white-50 small">{{ ans|linebreaksbr }}</p>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted small mb-0">No text responses submitted yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
/* Styling to make rating radio buttons render horizontally and look neat */
|
||||||
|
.rating-container ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rating-container li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rating-container input[type="radio"] {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
/* Add bootstrap styling to standard fields */
|
||||||
|
.survey-field-input select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 2.25rem 0.375rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #000;
|
||||||
|
background-clip: padding-box;
|
||||||
|
border: 1px solid #495057;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.survey-field-input textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #000;
|
||||||
|
background-clip: padding-box;
|
||||||
|
border: 1px solid #495057;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card bg-dark border-secondary text-white shadow">
|
||||||
|
<div class="card-header border-secondary py-4 text-center">
|
||||||
|
<h1 class="h3 text-white mb-2">{{ survey.name }}</h1>
|
||||||
|
{% if survey.description %}
|
||||||
|
<p class="text-muted mb-0">{{ survey.description|linebreaksbr }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if pre_or_post == 'PRE' %}
|
||||||
|
<div class="badge bg-primary mt-2">Pre-exam survey</div>
|
||||||
|
{% elif pre_or_post == 'POST' %}
|
||||||
|
<div class="badge bg-success mt-2">Post-exam survey</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
{% 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.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<!-- Context and routing parameters -->
|
||||||
|
<input type="hidden" name="pre_or_post" value="{{ pre_or_post }}">
|
||||||
|
<input type="hidden" name="content_type_id" value="{{ content_type_id|default:'' }}">
|
||||||
|
<input type="hidden" name="object_id" value="{{ object_id|default:'' }}">
|
||||||
|
<input type="hidden" name="cid" value="{{ cid|default:'' }}">
|
||||||
|
<input type="hidden" name="passcode" value="{{ passcode|default:'' }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next|default:'' }}">
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="mb-4 pb-4 border-bottom border-secondary">
|
||||||
|
<label class="form-label fw-semibold text-white d-block mb-2">
|
||||||
|
{{ forloop.counter }}. {{ field.label }}
|
||||||
|
{% if field.field.required %}<span class="text-danger">*</span>{% endif %}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="survey-field-input">
|
||||||
|
{% if field.field.widget.input_type == 'radio' %}
|
||||||
|
<div class="rating-container bg-black p-3 rounded border border-secondary d-inline-block">
|
||||||
|
{{ field }}
|
||||||
|
<div class="d-flex justify-content-between mt-1 small text-muted px-1" style="min-width: 200px;">
|
||||||
|
<span>Poor</span>
|
||||||
|
<span>Excellent</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ field }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ field.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="d-grid mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Submit Feedback</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card bg-dark border-secondary text-white text-center shadow py-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-success fs-1 mb-3">
|
||||||
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="card-title text-white mb-3">Thank you!</h2>
|
||||||
|
<p class="card-text text-muted mb-4">Your feedback has been successfully submitted. We appreciate your response.</p>
|
||||||
|
|
||||||
|
<a href="{{ next_url|default:'/' }}" class="btn btn-primary btn-lg px-5">
|
||||||
|
Continue <i class="bi bi-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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("<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"),
|
||||||
|
path("<int:pk>/take/", views.survey_take, name="survey_take"),
|
||||||
|
path("<int:pk>/results/", views.survey_results, name="survey_results"),
|
||||||
|
path("<int:pk>/export/", views.survey_export_csv, name="survey_export_csv"),
|
||||||
|
]
|
||||||
+358
@@ -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
|
||||||
@@ -341,6 +341,9 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.user.is_superuser or request.user|has_group:"cid_user_manager" %}
|
{% if request.user.is_superuser or request.user|has_group:"cid_user_manager" %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'survey:survey_list' %}"><i class="bi bi-patch-question me-1"></i> Feedback/Surveys</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{% url 'media_cleanup' %}">Unused media cleanup</a>
|
<a class="dropdown-item" href="{% url 'media_cleanup' %}">Unused media cleanup</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user