add simple survey support

This commit is contained in:
Ross
2026-06-29 09:04:28 +01:00
parent d07d88bdbf
commit 79ba4db4bf
33 changed files with 1687 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
# Survey app package
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class SurveyConfig(AppConfig):
default_auto_field = 'django.db.models.AutoField'
name = 'survey'
+82
View File
@@ -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
+76
View File
@@ -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.'),
),
]
View File
+156
View File
@@ -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 %}
+33
View File
@@ -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 %}
+106
View File
@@ -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 %}
+127
View File
@@ -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 %}
+122
View File
@@ -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 %}
+120
View File
@@ -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
+15
View File
@@ -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
View File
@@ -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