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
+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