try to improve the scores pages

This commit is contained in:
Ross
2026-07-06 12:33:30 +01:00
parent 7bd1475daf
commit 50b80fd0c7
12 changed files with 287 additions and 250 deletions
+95 -121
View File
@@ -3796,6 +3796,9 @@ class ExamViews(View, LoginRequiredMixin):
else:
user_answers_qs = self.UserAnswer.objects.filter(user=user, exam__id=pk).select_related("question")
if self.app_name in ("anatomy", "rapids"):
user_answers_qs = user_answers_qs.prefetch_related("question__answers")
# Map question_id -> UserAnswer for O(1) lookup
user_answers_map = {ua.question_id: ua for ua in user_answers_qs}
@@ -3927,19 +3930,7 @@ class ExamViews(View, LoginRequiredMixin):
return max_score
def exam_scores_all(self, request, pk):
"""The exam scores pages. Displays all user scores in a tabular format.
Args:
request (_type_): _description_
pk (_type_): _description_
Raises:
Http404: _description_
PermissionDenied: _description_
Returns:
_type_: _description_
"""
"""The exam scores pages. Displays all user scores in a tabular format."""
exam: ExamBase = get_object_or_404(self.Exam, pk=pk)
if not exam.exam_mode:
@@ -3949,21 +3940,17 @@ class ExamViews(View, LoginRequiredMixin):
if request.user not in exam.author.all():
raise PermissionDenied
# user_answers_and_marks = defaultdict(list)
user_answers_marks = defaultdict(list)
# user_answers = defaultdict(list)
# user_names = {}
# cid_passcodes = {}
table_only = request.GET.get("table_only") == "true" or request.headers.get("HX-Target") == "answers-table-container"
user_answers_marks = defaultdict(list)
by_question = defaultdict(dict)
unmarked = set()
cached_scores = True
valid_cid_users = set(exam.valid_cid_users.all().values_list("cid", flat=True))
valid_user_users = set(exam.valid_user_users.all().values_list("pk", flat=True))
if self.app_name in ("rapids"):
if self.app_name in ("rapids",):
user_answers_callstates = defaultdict(list)
user_answers_callstates_counted = {}
@@ -3977,34 +3964,31 @@ class ExamViews(View, LoginRequiredMixin):
questions = list(questions_qs)
question_index_map = {q: i for i, q in enumerate(questions)}
# We could prefetch the UserAnswers.answers here (if we didn't cache them)
# Also select_related the user to avoid per-answer user lookups in the loop
cid_user_answers = (
# Fetch and prefetch the UserAnswers
cid_user_answers_qs = (
self.UserAnswer.objects.select_related("question", "user")
.filter(question__in=questions, exam__id=pk)
)
if self.app_name in ("anatomy", "rapids"):
cid_user_answers_qs = cid_user_answers_qs.prefetch_related("question__answers")
cid_user_answers = list(cid_user_answers_qs)
question_number = len(questions)
cids = set()
plain_cids = set()
user_ids = set()
cids_user_id_map = {}
# Loop through all candidates
for cid_user_answer in cid_user_answers:
# Convoluted (probably...)
if cid_user_answer.user is None:
cid = f"c/{cid_user_answer.cid}"
cids_user_id_map[cid] = cid
# cid_passcodes[cid] = cid_user_answer.passcode
cids.add(cid)
plain_cids.add(cid_user_answer.cid)
else:
cid = f"u/{cid_user_answer.user.pk}"
# cids_user_id_map[cid] = cid_user_answer.user.username
name = f"{cid_user_answer.user.first_name} {cid_user_answer.user.last_name}"
if not name.strip():
name = cid_user_answer.user.username
@@ -4013,53 +3997,41 @@ class ExamViews(View, LoginRequiredMixin):
user_ids.add(cid_user_answer.user.pk)
s = cid_user_answer
# user_names[cid] = cid
q = cid_user_answer.question
# if not s:
# # skip if no answer
# user_answers_marks[cid].append(0)
# user_answers[cid].append("")
# by_question[q].append(("", 0))
# continue
ans = s.get_answer_string()
answer_score = s.get_answer_score()
if answer_score == "unmarked":
# Use precomputed map to avoid repeated scanning/indexing of questions
index = question_index_map.get(q)
if index is not None:
unmarked.add(index)
# user_answers[cid].append(ans)
user_answers_marks[cid].append(answer_score)
if self.app_name == "rapids":
callstate = s.get_answer_callstate()
by_question[q][cid] = (ans, answer_score, callstate)
user_answers_callstates[cid].append(callstate)
elif self.app_name in ("anatomy", "sbas", "longs", "shorts"):
by_question[q][cid] = (ans, answer_score)
else:
zipped_ans_scores = zip(ans, answer_score)
by_question[q][cid] = zipped_ans_scores
if table_only:
ans = s.get_answer_string()
if self.app_name == "rapids":
by_question[q][cid] = (ans, answer_score, callstate)
elif self.app_name in ("anatomy", "sbas", "longs", "shorts"):
by_question[q][cid] = (ans, answer_score)
else:
zipped_ans_scores = zip(ans, answer_score)
by_question[q][cid] = zipped_ans_scores
user_scores = {}
user_scores_normalised = {}
user_answer_count = {}
for user in user_answers_marks:
# For longs we have a default score of 3 (not 0) if unanswered
# so we need to check for those
if self.app_name == "longs":
for question in questions:
# If either question or user do not exist we give a default
# not answered == score of 3
if user not in by_question[question]:
# print("NOT in")
by_question[question][user] = ("Not answered", 3.0)
user_answers_marks[user].append(3.0)
missing_count = question_number - len(user_answers_marks[user])
for _ in range(missing_count):
user_answers_marks[user].append(3.0)
if table_only:
for question in questions:
if user not in by_question[question]:
by_question[question][user] = ("Not answered", 3.0)
if self.app_name in ("physics",):
user_scores[user] = sum(
@@ -4081,69 +4053,76 @@ class ExamViews(View, LoginRequiredMixin):
# ignore scores of 0 for stats
user_scores_list = [i for i in user_scores.values() if i > 0]
max_score = self.get_max_score(questions)
if len(user_scores_list) < 1:
mean = 0
median = 0
mode = 0
fig_html = ""
mean = 0
median = 0
mode = 0
fig_html = ""
if not table_only:
if len(user_scores_list) < 1:
mean = 0
median = 0
mode = 0
fig_html = ""
else:
# Exclude very low scores from statistics
lower_score_bound = max_score / 5
bounded_scores = [i for i in user_scores_list if i > lower_score_bound]
if bounded_scores:
user_scores_list = bounded_scores
mean = statistics.mean(user_scores_list)
median = statistics.median(user_scores_list)
try:
mode = statistics.mode(user_scores_list)
except statistics.StatisticsError:
mode = "No unique mode"
df = user_scores_list
fig = px.histogram(
df,
x=0,
title="{}: distribution of scores".format(exam),
labels={"0": "Score"},
height=400,
width=600,
)
fig_html = fig.to_html()
exam.stats_mean = mean
exam.stats_median = median
exam.stats_mode = mode
exam.stats_candidates = len(user_scores_list)
exam.stats_max_possible = max_score
exam.stats_min = min(user_scores_list)
exam.stats_max = max(user_scores_list)
exam.stats_graph = fig_html
user_data = {}
for u in cids:
user_data[u] = {
"score": user_scores[u],
"normalised_score": user_scores_normalised[u],
}
if self.app_name == "rapids":
counted_callstates = dict(Counter(user_answers_callstates[u]))
user_data[u]["callstates"] = str(counted_callstates)
user_answers_callstates_counted[u] = counted_callstates
exam.user_scores = user_data
exam.save()
else:
# Exclude very low scores from statistics
lower_score_bound = max_score / 5
bounded_scores = [i for i in user_scores_list if i > lower_score_bound]
if bounded_scores:
user_scores_list = bounded_scores
mean = statistics.mean(user_scores_list)
median = statistics.median(user_scores_list)
try:
mode = statistics.mode(user_scores_list)
except statistics.StatisticsError:
mode = "No unique mode"
df = user_scores_list
fig = px.histogram(
df,
x=0,
title="{}: distribution of scores".format(exam),
labels={"0": "Score"},
height=400,
width=600,
)
fig_html = fig.to_html()
exam.stats_mean = mean
exam.stats_median = median
exam.stats_mode = mode
exam.stats_candidates = len(user_scores_list)
exam.stats_max_possible = max_score
exam.stats_min = min(user_scores_list)
exam.stats_max = max(user_scores_list)
exam.stats_graph = fig_html
user_data = {}
for u in cids:
# uid = cids_user_id_map[u]
user_data[u] = {
"score": user_scores[u],
"normalised_score": user_scores_normalised[u],
}
if self.app_name == "rapids":
counted_callstates = dict(Counter(user_answers_callstates[u]))
user_data[u]["callstates"] = str(counted_callstates)
user_answers_callstates_counted[u] = counted_callstates
exam.user_scores = user_data
exam.save()
# If table_only and rapids, populate user_answers_callstates_counted
if self.app_name == "rapids":
for u in cids:
user_answers_callstates_counted[u] = dict(Counter(user_answers_callstates[u]))
missing_user_ids = valid_user_users - user_ids
missing_users = []
if missing_user_ids:
missing_users = User.objects.filter(pk__in=missing_user_ids)
@@ -4152,19 +4131,13 @@ class ExamViews(View, LoginRequiredMixin):
"cids": sorted(cids),
"missing_cids": valid_cid_users - plain_cids,
"missing_users": missing_users,
# "cid_passcodes": cid_passcodes,
"exam": exam,
"unmarked": unmarked,
"questions": questions,
"question_number": question_number,
"by_question": by_question,
# "user_answers": dict(user_answers),
# "user_answers_marks": dict(user_answers_marks),
"user_scores": user_scores,
"user_scores_normalised": user_scores_normalised,
# "user_scores_list": user_scores_list,
# "user_names": user_names,
# "user_answers_and_marks": user_answers_and_marks,
"user_answer_count": user_answer_count,
"cids_user_id_map": cids_user_id_map,
"max_score": max_score,
@@ -4174,6 +4147,7 @@ class ExamViews(View, LoginRequiredMixin):
"plot": fig_html,
"cached_scores": cached_scores,
"can_edit": exam.check_user_can_edit(request.user),
"base_template": "generic/exam_scores_partial_base.html" if table_only else "generic/exam_scores_base.html",
}
if self.app_name == "rapids":