try to improve the scores pages
This commit is contained in:
@@ -85,57 +85,19 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h5 class="mb-0">Answers as a table</h5>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#answers-table" aria-expanded="true">Toggle answers</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#answers-table-container" aria-expanded="true">Toggle answers</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse show" id="answers-table">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover table-sm col-sm small">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th>Candidate</th>
|
||||
{% for cid in cids %}
|
||||
{% comment %} <th><a href="{% url exam.app_name|add:':exam_scores_cid_user' exam.pk cid %}">{{cid}}</a></th> {% endcomment %}
|
||||
<th>
|
||||
|
||||
{% if cid|slice:":1" == "u" %}
|
||||
<a href="{% url exam.app_name|add:':exam_scores_user_admin' exam.pk cid|slice:'2:' %}">
|
||||
{% else %}
|
||||
|
||||
<a href="{% url exam.app_name|add:':exam_scores_cid_user_admin' exam.pk cid|slice:'2:' %}">
|
||||
|
||||
{% endif %}
|
||||
|
||||
{{cids_user_id_map|get_item:cid}}</a></th>
|
||||
{% endfor %}
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
{% block table_answers %}
|
||||
{% endblock table_answers %}
|
||||
{% comment %} {% for question in questions %}
|
||||
<tr>
|
||||
<td><a href="{% url exam.app_name|add:':mark' exam_pk=exam.pk sk=forloop.counter0 %}">Question {{forloop.counter}}</a>
|
||||
{% if question.normal %}
|
||||
[N]
|
||||
{% else %}
|
||||
[A]
|
||||
{% endif %}
|
||||
</td>
|
||||
{% for cid in cids %}
|
||||
{% with by_question|get_item:question|get_item:cid as ans_score %}
|
||||
<td class="user-answer-score-{{ans_score.1}}" title="answer score: {{ans_score.1}} [{{ans_score.2}}]">{{ans_score.0}}</td>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %} {% endcomment %}
|
||||
<tr>
|
||||
<td>Score:</td>
|
||||
{% for cid in cids %}
|
||||
<td>{{user_scores|get_item:cid}}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
<div class="collapse show" id="answers-table-container">
|
||||
<div hx-get="{% url exam.app_name|add:':exam_scores_all' exam.pk %}?table_only=true"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-info" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="text-muted mt-2">Loading answers table...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="table-responsive" id="answers-table">
|
||||
<table class="table table-dark table-striped table-hover table-sm col-sm small">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th>Candidate</th>
|
||||
{% for cid in cids %}
|
||||
<th>
|
||||
{% if cid|slice:":1" == "u" %}
|
||||
<a href="{% url exam.app_name|add:':exam_scores_user_admin' exam.pk cid|slice:'2:' %}">
|
||||
{% else %}
|
||||
<a href="{% url exam.app_name|add:':exam_scores_cid_user_admin' exam.pk cid|slice:'2:' %}">
|
||||
{% endif %}
|
||||
{{cids_user_id_map|get_item:cid}}</a>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
{% block table_answers %}
|
||||
{% endblock table_answers %}
|
||||
<tr>
|
||||
<td>Score:</td>
|
||||
{% for cid in cids %}
|
||||
<td>{{user_scores|get_item:cid}}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
+95
-121
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user