add simple survey support
This commit is contained in:
+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