add simple survey support
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user