Files
penracourses/physics/views.py
T

797 lines
26 KiB
Python

from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from generic.models import CidUser
from physics.decorators import user_is_author_or_physics_checker
from physics.filters import QuestionFilter, UserAnswerFilter
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from physics.tables import QuestionTable, UserAnswerTable
from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import csrf_exempt
from django import forms
# from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic import ListView
from django.db.models.functions import Lower
from django.core.cache import cache
from django.urls import reverse_lazy, reverse
from django.http import Http404, HttpResponseBadRequest, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django_filters.views import FilterView
from django_tables2.views import SingleTableMixin
from reversion.views import RevisionMixin
from .models import Question, Category, Exam, UserAnswer
from collections import defaultdict
import os
import base64
import mimetypes
import json
import statistics
import plotly.express as px
from generic.views import (
AuthorRequiredMixin,
ExamCloneMixin,
ExamCreateBase,
ExamDeleteBase,
ExamGroupsUpdateBase,
ExamUpdateBase,
ExamViews,
GenericViewBase,
exam_inactive,
)
from django.core.exceptions import PermissionDenied
from .forms import ExamGroupsForm, ExamMarkerForm, UserAnswerForm, ExamAuthorForm, ExamForm
from .decorators import (
user_is_author_or_physics_checker,
user_is_exam_author_or_physics_checker,
)
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
if self.request.user.groups.filter(name="physics_checker").exists():
return obj
if self.request.user not in obj.author.all():
raise PermissionDenied() # or Http404
return obj
def question_list(request):
questions = Question.objects.all()
return render(request, "physics/question_list.html", {"questions": questions})
@login_required
@user_is_author_or_physics_checker
def question_detail(request, pk):
question = get_object_or_404(Question, pk=pk)
return render(request, "physics/question_detail.html", {"question": question})
@login_required
def active_exams(request):
exams = Exam.objects.all()
print(exams)
active_exams = []
for exam in exams:
if exam.active:
active_exams.append(exam)
return render(request, "physics/available_exam_list.html", {"exams": active_exams})
def exam_take_old(request, pk):
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
raise exam_inactive(request)
questions = exam.get_questions()
return render(
request,
"physics/exam_take_old.html",
{
"exam": exam,
"questions": questions,
},
)
def exam_start(request, pk):
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
return exam_inactive(request, context={"exam": exam})
return render(
request,
"physics/exam_start.html",
{
"exam": exam,
# This may be overly comlicated but if the logged in user
# does not have access we give the option to take it as a cid user
"valid_user": exam.check_logged_in_user(request),
},
)
def exam_complete(request, pk, cid=None, passcode=None):
exam = get_object_or_404(Exam, pk=pk)
exam.check_user_can_take(cid, passcode, request.user)
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
cid_user_exam.complete_exam()
return HttpResponse("<div role='alert' class='alert alert-info'>Exam completed</div>")
def exam_take_overview(request, pk, cid=None, passcode=None):
exam = get_object_or_404(Exam, pk=pk)
exam.check_user_can_take(cid, passcode, request.user)
questions = exam.get_questions()
if cid is not None:
answers = UserAnswer.objects.filter(cid=cid, exam=exam)
else:
answers = UserAnswer.objects.filter(user=request.user, exam=exam)
answer_question_map = {}
for ans in answers:
answer_question_map[ans.question] = ans
question_answer_tuples = []
answer_count = 0
for q in questions:
# if q in answer_question_map and answer_question_map[q].answer:
if q in answer_question_map: # might need to improve this
question_answer_tuples.append((q, answer_question_map[q]))
answer_count += 1
else:
question_answer_tuples.append((q, None))
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
return render(
request,
"physics/exam_take_overview.html",
{
"exam": exam,
"cid": cid,
"question_answer_tuples": question_answer_tuples,
"answer_count": answer_count,
"exam_length": len(questions),
"passcode": passcode,
"cid_user_exam": cid_user_exam,
},
)
def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
"""Display and handle answers for a single question in a physics exam.
Behavior and invariants:
- Uses a single canonical list of questions (from `exam.get_questions()`) for
selection, navigation and sizing so indexes cannot drift.
- Validates the incoming question index and raises 404 for invalid values.
- Supports candidate (cid) and logged-in user answers.
"""
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
return exam_inactive(request, context={"exam": exam})
exam.check_user_can_take(cid, passcode, request.user)
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
# Canonical questions list for consistent indexing/navigation
questions = list(exam.get_questions())
# Normalize and validate provided index
try:
index = int(sk)
except Exception:
raise Http404("Invalid question index")
if index < 0 or index >= len(questions):
raise Http404("Question not found in exam")
question = questions[index]
exam_length = len(questions)
# local numeric position equals the validated index
pos = index
if cid is not None:
answer = question.cid_user_answers.filter(cid=cid, exam=exam).first()
else:
answer = question.cid_user_answers.filter(user=request.user, exam=exam).first()
previous = -1
if sk > 0:
previous = sk - 1
next = sk + 1
if sk == exam_length - 1:
next = False
if request.method == "POST":
if answer:
form = UserAnswerForm(request.POST, instance=answer)
else:
form = UserAnswerForm(request.POST)
if form.is_valid() and not exam.publish_results and not cid_user_exam.completed:
answer = form.save(commit=False)
if cid is not None:
answer.cid = cid
else:
answer.user = request.user
answer.question = question
answer.exam = exam
# answer.published_date = timezone.now()
answer.save()
cid_user_exam.end_time = timezone.now()
cid_user_exam.save()
if cid is not None:
kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
take_url = "physics:exam_take"
finish_url = "physics:exam_take_overview"
else:
kwargs = {"pk": pk}
take_url = "physics:exam_take_user"
finish_url = "physics:exam_take_overview_user"
# Support HTMX: if this is an HX request, return the fragment HTML for the
# destination question instead of issuing a full-page redirect. This lets
# the client swap only the question fragment.
is_htmx = request.headers.get("HX-Request") == "true" or request.META.get("HTTP_HX_REQUEST")
if "next" in request.POST:
if not next:
return HttpResponseBadRequest("Invalid request")
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos + 1, cid=cid, passcode=passcode)
return redirect(take_url, sk=pos + 1, **kwargs)
elif "previous" in request.POST:
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos - 1, cid=cid, passcode=passcode)
return redirect(take_url, sk=pos - 1, **kwargs)
elif "finish" in request.POST:
return redirect(finish_url, **kwargs)
elif "save" in request.POST:
# Save action should not trigger a full-page load for HTMX clients.
if is_htmx:
# Re-render the same fragment so the client can swap it in-place
return exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
# Non-HTMX clients get redirected back to the same question page
return redirect(take_url, sk=pos, **kwargs)
elif "goto" in request.POST:
dest = int(request.POST.get("goto"))
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=dest, cid=cid, passcode=passcode)
return redirect(take_url, sk=dest, **kwargs)
# For HTMX POSTs that don't match a navigation action (eg validation errors),
# return the fragment so the client receives only the partial HTML.
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
else:
form = UserAnswerForm(instance=answer)
saved_answer = False
if answer is not None:
saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]
return render(
request,
"physics/exam_take.html",
{
"form": form,
"cid": cid,
"exam": exam,
"question": question,
"next": next,
"previous": previous,
"exam_length": exam_length,
"pos": pos,
"saved_answer": saved_answer,
"passcode": passcode,
"cid_user_exam": cid_user_exam,
},
)
def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
"""Return an HTMX partial containing the question form and navigation controls.
This view mirrors the GET rendering logic of `exam_take` but returns a fragment
suitable for being loaded via HTMX into the page. It intentionally does not
process POSTs (the main `exam_take` endpoint handles form submissions and
redirects) to keep fragment logic focused on rendering.
"""
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
return exam_inactive(request, context={"exam": exam})
exam.check_user_can_take(cid, passcode, request.user)
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
# Canonical questions list for consistent indexing/navigation
questions = list(exam.get_questions())
try:
index = int(sk)
except Exception:
raise Http404("Invalid question index")
if index < 0 or index >= len(questions):
raise Http404("Question not found in exam")
question = questions[index]
exam_length = len(questions)
pos = index
if cid is not None:
answer = question.cid_user_answers.filter(cid=cid, exam=exam).first()
else:
answer = question.cid_user_answers.filter(user=request.user, exam=exam).first()
if answer is not None:
form = UserAnswerForm(instance=answer)
saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]
else:
form = UserAnswerForm()
saved_answer = False
previous = -1
if sk > 0:
previous = sk - 1
next = sk + 1
if sk == exam_length - 1:
next = False
return render(
request,
"physics/partials/exam_take_fragment.html",
{
"form": form,
"cid": cid,
"exam": exam,
"question": question,
"next": next,
"previous": previous,
"exam_length": exam_length,
"pos": pos,
"saved_answer": saved_answer,
"passcode": passcode,
"cid_user_exam": cid_user_exam,
},
)
# def loadJsonAnswer(answer):
# # As access is not restricted make sure the data appears valid
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
# return False, JsonResponse({"success": False, "error": "invalid"})
#
# # The model should catch invalid data but this should be less intensive
# max_int = 2147483647
# if answer["cid"] > max_int or answer["eid"] > max_int:
# return False, JsonResponse({"success": False, "error": "invalid candidate id"})
#
# exam = get_object_or_404(Exam, pk=answer["eid"])
#
# # if not exam.active:
# # return False, JsonResponse(
# # {"success": False, "error": "No active exam: {}".format(answer["eid"])}
# # )
#
# questions = defaultdict(dict)
#
# for q, n, a in answer["ans"]:
# questions[q][n] = a
# pass
#
# for qid in questions:
# exiting_answers = UserAnswer.objects.filter(
# question__id=qid, exam__id=answer["eid"], cid=answer["cid"]
# )
#
# a = questions[qid]["a"]
# b = questions[qid]["b"]
# c = questions[qid]["c"]
# d = questions[qid]["d"]
# e = questions[qid]["e"]
#
# if not exiting_answers:
# ans = UserAnswer(a=a, b=b, c=c, d=d, e=e, cid=answer["cid"])
# ans.question_id = qid
# ans.exam_id = answer["eid"]
#
# ans.full_clean()
#
# ans.save()
# else:
# # Update an existing answer
# # should never be more than one (famous last words)
# ans = exiting_answers[0]
# ans.a = a
# ans.b = b
# ans.c = c
# ans.d = d
# ans.e = e
# ans.full_clean()
# ans.save()
#
# return True, None
GenericExamViews = ExamViews(Exam, Question, None, UserAnswer, "physics", "physics")
GenericViews = GenericViewBase("physics", Question, UserAnswer, Exam)
class ExamCreate(ExamCreateBase):
model = Exam
form_class = ExamForm
class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
model = Exam
form_class = ExamForm
class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
model = Exam
class ExamClone(AuthorOrCheckerRequiredMixin, ExamCloneMixin, ExamCreate):
"""Clone exam view"""
class ExamAuthorUpdate(
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
):
model = Exam
form_class = ExamAuthorForm
template_name = "author_form.html"
class ExamMarkersUpdate(
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
):
model = Exam
form_class = ExamMarkerForm
template_name = "markers_form.html"
class ExamGroupsUpdate(ExamGroupsUpdateBase):
model = Exam
form_class = ExamGroupsForm
class QuestionView(
LoginRequiredMixin, SingleTableMixin, FilterView, AuthorOrCheckerRequiredMixin
):
model = Question
table_class = QuestionTable
template_name = "question_table_view.html"
filterset_class = QuestionFilter
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["app_name"] = "physics"
return context
class UserAnswerView(AuthorOrCheckerRequiredMixin, LoginRequiredMixin, DetailView):
model = UserAnswer
class UserAnswerTableView(
AuthorOrCheckerRequiredMixin, LoginRequiredMixin, SingleTableMixin, FilterView
):
model = UserAnswer
table_class = UserAnswerTable
template_name = "physics/user_answer_question_view.html"
filterset_class = UserAnswerFilter
class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
model = UserAnswer
template_name = "user_answer_delete.html"
success_url = reverse_lazy("physics:user_answer_table_view")
@login_required
def exam_review_question_responses(request, pk: int, q_index: int):
"""Return the responses partial for a given exam question (HTMX-friendly)."""
exam = get_object_or_404(Exam, pk=pk)
questions = exam.get_questions()
try:
question = questions[q_index]
except Exception:
raise Http404("Question not found in exam")
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "physics",
}
# Prefilter user answers for this exam to avoid template-side filtering
try:
exam_user_answers = question.cid_user_answers.filter(exam=exam).select_related("user")
except Exception:
exam_user_answers = question.cid_user_answers.all()
# Default: anonymise unless reveal=1 is provided
reveal_flag = str(request.GET.get("reveal", "")).lower() in ("1", "true", "on")
context["reveal_respondents"] = reveal_flag
context["exam_user_answers"] = exam_user_answers
return render(request, "physics/partials/exam_review_question_responses_fragment.html", context)
@login_required
def exam_review_question_summary(request, pk: int, q_index: int):
"""Return the aggregated response summary partial for a given exam question (HTMX-friendly)."""
exam = get_object_or_404(Exam, pk=pk)
questions = exam.get_questions()
try:
question = questions[q_index]
except Exception:
raise Http404("Question not found in exam")
# Aggregate user answers for this question & exam
ua_qs = question.cid_user_answers.filter(exam=exam)
total_responses = ua_qs.count()
# Physics answers may be stored as tuples/lists or named fields; attempt to aggregate
counts = defaultdict(int)
other_count = 0
# Try simple 'answer' field aggregation first; if not present, fall
# back to iterating objects and using get_answer() or composing parts.
try:
for ans in ua_qs.values_list("answer", flat=True):
if not ans:
other_count += 1
continue
if isinstance(ans, (list, tuple)):
key = ", ".join([str(x) for x in ans])
else:
key = str(ans)
counts[key] += 1
except Exception:
for ua in ua_qs:
ans_val = None
try:
if hasattr(ua, "get_answer"):
ans_val = ua.get_answer()
except Exception:
ans_val = None
if not ans_val:
parts = []
for fld in ("a", "b", "c", "d", "e"):
if hasattr(ua, fld):
v = getattr(ua, fld)
if v is None:
continue
if isinstance(v, (list, tuple)):
parts.extend([str(x) for x in v])
else:
parts.append(str(v))
if parts:
ans_val = ", ".join(parts)
if not ans_val:
other_count += 1
continue
counts[ans_val] += 1
counts["unanswered"] = other_count
pcts = {}
if total_responses:
for k, v in counts.items():
pcts[k] = round(100.0 * v / total_responses, 1)
else:
for k in counts.keys():
pcts[k] = 0.0
# Provide an explicit iterable of structured items for templates to consume.
# Each item includes the original key, count, pct and optional per-part
# breakdown (list of {value, is_correct}) for physics multi-part answers.
exam_response_items = []
parts_names = ["a", "b", "c", "d", "e"]
for k, v in sorted(counts.items(), key=lambda x: x[1], reverse=True):
item = {"key": k, "count": v, "pct": pcts.get(k, 0.0), "parts": None}
# Attempt to parse comma-separated boolean-like keys into parts
try:
if isinstance(k, str) and "," in k:
parts_raw = [p.strip() for p in k.split(",")]
if len(parts_raw) == 5:
parts_details = []
for idx, sval in enumerate(parts_raw):
sval_l = sval.lower()
if sval_l in ("true", "t", "1", "yes"):
val = True
elif sval_l in ("false", "f", "0", "no"):
val = False
elif sval_l in ("none", "", "not answered", "not_answered"):
val = None
else:
# leave as string fallback
val = sval
# determine correctness where possible
is_correct = None
try:
correct_val = getattr(question, f"{parts_names[idx]}_answer")
if isinstance(val, bool) and isinstance(correct_val, bool):
is_correct = val == correct_val
except Exception:
is_correct = None
parts_details.append({"value": val, "is_correct": is_correct})
item["parts"] = parts_details
except Exception:
item["parts"] = None
exam_response_items.append(item)
# Compute percent correct using per-answer scoring
correct_count = None
correct_pct = None
try:
# For physics, count true/false sub-questions individually rather than
# requiring a full-match across all parts.
if total_responses:
correct_subitems = 0
total_subitems = 0
for ua in ua_qs:
try:
score = ua.get_answer_score()
except Exception:
continue
if isinstance(score, (list, tuple)) and score:
for s in score:
if isinstance(s, (int, float)) and s > 0:
correct_subitems += 1
total_subitems += len(score)
else:
if isinstance(score, (int, float)):
if score > 0:
correct_subitems += 1
total_subitems += 1
correct_count = correct_subitems
if total_subitems:
correct_pct = round(100.0 * correct_subitems / total_subitems, 1)
else:
correct_pct = 0.0
else:
correct_count = 0
correct_pct = 0.0
except Exception:
correct_count = None
correct_pct = None
# Per-part (a-e) true/false aggregation. Each physics question has 5 parts
# stored as boolean fields on UserAnswer (a,b,c,d,e). Build counts and
# percentages for each part so templates can render them as separate TF
# sub-questions.
per_part_stats = []
try:
parts = ["a", "b", "c", "d", "e"]
# initialize counters
for idx, part in enumerate(parts):
per_part_stats.append({
"part": part,
"label": getattr(question, part).strip() if getattr(question, part, None) else f"Part {idx+1}",
"true_count": 0,
"false_count": 0,
"total": 0,
"correct_count": 0,
"correct_pct": 0.0,
"pct_true": 0.0,
})
for ua in ua_qs:
for idx, part in enumerate(parts):
try:
val = getattr(ua, part)
except Exception:
continue
if val is True:
per_part_stats[idx]["true_count"] += 1
elif val is False:
per_part_stats[idx]["false_count"] += 1
# count only explicit booleans as total
if isinstance(val, bool):
per_part_stats[idx]["total"] += 1
# correct counting
try:
correct_val = getattr(question, f"{part}_answer")
except Exception:
correct_val = None
if isinstance(val, bool) and isinstance(correct_val, bool):
if val == correct_val:
per_part_stats[idx]["correct_count"] += 1
# compute percentages
for stats in per_part_stats:
if stats["total"]:
stats["pct_true"] = round(100.0 * stats["true_count"] / stats["total"], 1)
stats["correct_pct"] = round(100.0 * stats["correct_count"] / stats["total"], 1)
else:
stats["pct_true"] = 0.0
stats["correct_pct"] = 0.0
except Exception:
per_part_stats = []
# Provide a best-effort correct answer display
correct_answer = None
try:
correct_answer = question.get_answers()
if isinstance(correct_answer, (list, tuple)):
correct_answer = ", ".join([str(x) for x in correct_answer])
else:
correct_answer = str(correct_answer)
except Exception:
correct_answer = None
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "physics",
"exam_response_counts": counts,
"exam_response_items": exam_response_items,
"exam_response_pcts": pcts,
"exam_response_total": total_responses,
"exam_response_correct_count": correct_count,
"exam_response_correct_pct": correct_pct,
"exam_response_texts": {},
"correct_answer": correct_answer,
"per_part_stats": per_part_stats,
}
return render(request, "physics/partials/exam_review_question_summary_fragment.html", context)