try to improve the scores pages
This commit is contained in:
+53
-34
@@ -338,7 +338,21 @@ class Answer(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
return super(Answer, self).save(*args, **kwargs)
|
||||
res = super(Answer, self).save(*args, **kwargs)
|
||||
# Update matching UserAnswer objects in real-time
|
||||
UserAnswer.objects.filter(
|
||||
question=self.question,
|
||||
answer_compare__iexact=self.answer_compare
|
||||
).update(score=self.status)
|
||||
return res
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Update matching UserAnswer objects back to UNMARKED in real-time
|
||||
UserAnswer.objects.filter(
|
||||
question=self.question,
|
||||
answer_compare__iexact=self.answer_compare
|
||||
).update(score=Answer.MarkOptions.UNMARKED)
|
||||
return super(Answer, self).delete(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
if self.answer:
|
||||
@@ -551,11 +565,30 @@ class UserAnswer(UserAnswerBase):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
|
||||
# Auto-mark on save from existing template answers
|
||||
ans = self.answer_compare
|
||||
if ans == "":
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
else:
|
||||
marked_ans = Answer.objects.filter(
|
||||
question_id=self.question_id,
|
||||
answer_compare__iexact=ans
|
||||
).first()
|
||||
if marked_ans is not None:
|
||||
self.score = marked_ans.status
|
||||
else:
|
||||
self.score = Answer.MarkOptions.UNMARKED
|
||||
|
||||
return super(UserAnswer, self).save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
if self.answer:
|
||||
self.answer = self.answer.strip()
|
||||
self.answer_compare = get_answer_compare(self.answer)
|
||||
else:
|
||||
self.answer = ""
|
||||
self.answer_compare = ""
|
||||
|
||||
# def get_compare_string(self):
|
||||
# # strip here should be unneccasry (providing clean is now working)
|
||||
@@ -569,30 +602,34 @@ class UserAnswer(UserAnswerBase):
|
||||
return self.answer
|
||||
|
||||
def get_answer_score(self, cached=True):
|
||||
if cached and self.score:
|
||||
if cached and self.score and self.score != Answer.MarkOptions.UNMARKED:
|
||||
if self.score == Answer.MarkOptions.CORRECT:
|
||||
mark = 2
|
||||
return 2
|
||||
elif self.score == Answer.MarkOptions.HALF_MARK:
|
||||
mark = 1
|
||||
return 1
|
||||
elif self.score == Answer.MarkOptions.INCORRECT:
|
||||
mark = 0
|
||||
|
||||
return mark
|
||||
return 0
|
||||
|
||||
q = self.question
|
||||
ans = self.answer_compare
|
||||
|
||||
if ans == "":
|
||||
self.score == Answer.MarkOptions.INCORRECT
|
||||
self.save()
|
||||
if self.score != Answer.MarkOptions.INCORRECT:
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
self.save()
|
||||
return 0
|
||||
|
||||
marked_ans = q.answers.filter(answer_compare__iexact=ans).first()
|
||||
marked_ans = None
|
||||
for a in q.answers.all():
|
||||
if a.answer_compare.lower() == ans.lower():
|
||||
marked_ans = a
|
||||
break
|
||||
|
||||
mark = "unmarked"
|
||||
if marked_ans is not None:
|
||||
self.score = marked_ans.status
|
||||
self.save()
|
||||
if self.score != marked_ans.status:
|
||||
self.score = marked_ans.status
|
||||
self.save()
|
||||
|
||||
if marked_ans.status == Answer.MarkOptions.CORRECT:
|
||||
mark = 2
|
||||
@@ -600,27 +637,9 @@ class UserAnswer(UserAnswerBase):
|
||||
mark = 1
|
||||
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
|
||||
mark = 0
|
||||
|
||||
return mark
|
||||
|
||||
if (
|
||||
q.answers.filter(
|
||||
answer_compare__iexact=ans, status=Answer.MarkOptions.CORRECT
|
||||
).first()
|
||||
is not None
|
||||
):
|
||||
mark = 2
|
||||
elif (
|
||||
q.answers.filter(
|
||||
answer_compare__iexact=ans, status=Answer.MarkOptions.HALF_MARK
|
||||
).first()
|
||||
is not None
|
||||
):
|
||||
mark = 1
|
||||
elif q.answers.filter(
|
||||
answer_compare__iexact=ans, status=Answer.MarkOptions.INCORRECT
|
||||
).first():
|
||||
mark = 0
|
||||
else:
|
||||
mark = "unmarked"
|
||||
if self.score != Answer.MarkOptions.UNMARKED:
|
||||
self.score = Answer.MarkOptions.UNMARKED
|
||||
self.save()
|
||||
|
||||
return mark
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends 'generic/exam_scores_base.html' %}
|
||||
{% extends base_template|default:'generic/exam_scores_base.html' %}
|
||||
|
||||
{% block table_answers %}
|
||||
{% for question in questions %}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
{% extends 'generic/exam_scores_base.html' %}
|
||||
{% extends base_template|default:'generic/exam_scores_base.html' %}
|
||||
|
||||
{% block table_answers %}
|
||||
{% for question in questions %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends 'generic/exam_scores_base.html' %}
|
||||
{% extends base_template|default:'generic/exam_scores_base.html' %}
|
||||
|
||||
{% block table_answers %}
|
||||
{% for question in questions %}
|
||||
|
||||
+89
-37
@@ -141,7 +141,30 @@ class Answer(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
return super(Answer, self).save(*args, **kwargs)
|
||||
res = super(Answer, self).save(*args, **kwargs)
|
||||
# Update matching UserAnswer objects in real-time
|
||||
status = self.status
|
||||
callstate = UserAnswer.CallStateOptions.INCORRECTCALL
|
||||
if status == Answer.MarkOptions.CORRECT:
|
||||
callstate = UserAnswer.CallStateOptions.CORRECT
|
||||
elif status == Answer.MarkOptions.HALF_MARK:
|
||||
callstate = UserAnswer.CallStateOptions.PARTIALCALL
|
||||
|
||||
UserAnswer.objects.filter(
|
||||
question=self.question,
|
||||
normal=False,
|
||||
answer_compare__iexact=self.answer_compare
|
||||
).update(score=status, callstate=callstate)
|
||||
return res
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Update matching UserAnswer objects back to UNMARKED in real-time
|
||||
UserAnswer.objects.filter(
|
||||
question=self.question,
|
||||
normal=False,
|
||||
answer_compare__iexact=self.answer_compare
|
||||
).update(score=Answer.MarkOptions.UNMARKED, callstate=None)
|
||||
return super(Answer, self).delete(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
if self.answer:
|
||||
@@ -823,10 +846,38 @@ class UserAnswer(UserAnswerBase):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
|
||||
# Auto-mark on save based on existing template answers
|
||||
q = self.question
|
||||
if q.normal != self.normal:
|
||||
self.callstate = UserAnswer.CallStateOptions.OVERCALL if q.normal else UserAnswer.CallStateOptions.UNDERCALL
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
elif q.normal and self.normal:
|
||||
self.callstate = UserAnswer.CallStateOptions.CORRECT
|
||||
self.score = Answer.MarkOptions.CORRECT
|
||||
else:
|
||||
ans = self.answer_compare
|
||||
if ans == "":
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
self.callstate = UserAnswer.CallStateOptions.INCORRECTCALL
|
||||
else:
|
||||
marked_ans = Answer.objects.filter(question_id=self.question_id, answer_compare__iexact=ans).first()
|
||||
if marked_ans is not None:
|
||||
self.score = marked_ans.status
|
||||
if marked_ans.status == Answer.MarkOptions.CORRECT:
|
||||
self.callstate = UserAnswer.CallStateOptions.CORRECT
|
||||
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
|
||||
self.callstate = UserAnswer.CallStateOptions.PARTIALCALL
|
||||
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
|
||||
self.callstate = UserAnswer.CallStateOptions.INCORRECTCALL
|
||||
else:
|
||||
self.score = Answer.MarkOptions.UNMARKED
|
||||
self.callstate = None
|
||||
return super(UserAnswer, self).save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
self.score = Answer.MarkOptions.UNMARKED
|
||||
if not self.score:
|
||||
self.score = Answer.MarkOptions.UNMARKED
|
||||
if self.answer:
|
||||
self.answer = self.answer.strip()
|
||||
self.answer_compare = get_answer_compare(self.answer)
|
||||
@@ -855,66 +906,67 @@ class UserAnswer(UserAnswerBase):
|
||||
return self.callstate
|
||||
|
||||
def get_answer_score(self, cached=True):
|
||||
if cached and self.score:
|
||||
print("CACHED")
|
||||
if cached and self.score and self.score != Answer.MarkOptions.UNMARKED:
|
||||
if self.score == Answer.MarkOptions.CORRECT:
|
||||
mark = 2
|
||||
return 2
|
||||
elif self.score == Answer.MarkOptions.HALF_MARK:
|
||||
mark = 1
|
||||
return 1
|
||||
elif self.score == Answer.MarkOptions.INCORRECT:
|
||||
mark = 0
|
||||
|
||||
return mark
|
||||
print("NOT CACHED")
|
||||
return 0
|
||||
|
||||
q = self.question
|
||||
|
||||
# First step we check that the normal/abnormal states match
|
||||
if q.normal != self.normal:
|
||||
if q.normal:
|
||||
self.callstate = UserAnswer.CallStateOptions.OVERCALL
|
||||
else:
|
||||
self.callstate = UserAnswer.CallStateOptions.UNDERCALL
|
||||
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
self.save()
|
||||
# If they don't match the answer is wrong (score is 0)
|
||||
new_callstate = UserAnswer.CallStateOptions.OVERCALL if q.normal else UserAnswer.CallStateOptions.UNDERCALL
|
||||
if self.score != Answer.MarkOptions.INCORRECT or self.callstate != new_callstate:
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
self.callstate = new_callstate
|
||||
self.save()
|
||||
return 0
|
||||
# If both are normal full marks
|
||||
elif q.normal and self.normal:
|
||||
self.callstate = UserAnswer.CallStateOptions.CORRECT
|
||||
self.score = Answer.MarkOptions.CORRECT
|
||||
self.save()
|
||||
if self.score != Answer.MarkOptions.CORRECT or self.callstate != UserAnswer.CallStateOptions.CORRECT:
|
||||
self.score = Answer.MarkOptions.CORRECT
|
||||
self.callstate = UserAnswer.CallStateOptions.CORRECT
|
||||
self.save()
|
||||
return 2
|
||||
|
||||
# Then compare answer strings (as per anatomy questions)
|
||||
ans = self.answer_compare
|
||||
|
||||
if ans == "":
|
||||
self.score == Answer.MarkOptions.INCORRECT
|
||||
self.save()
|
||||
if self.score != Answer.MarkOptions.INCORRECT or self.callstate != UserAnswer.CallStateOptions.INCORRECTCALL:
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
self.callstate = UserAnswer.CallStateOptions.INCORRECTCALL
|
||||
self.save()
|
||||
return 0
|
||||
|
||||
# TODO: this should be cleaned up as we don't want duplicates...
|
||||
# try:
|
||||
# marked_ans = q.answers.get(answer_compare__iexact=ans)
|
||||
# except Answer.DoesNotExist:
|
||||
# marked_ans = None
|
||||
marked_ans = q.answers.filter(answer_compare__iexact=ans).first()
|
||||
marked_ans = None
|
||||
for a in q.answers.all():
|
||||
if a.answer_compare.lower() == ans.lower():
|
||||
marked_ans = a
|
||||
break
|
||||
|
||||
mark = "unmarked"
|
||||
if marked_ans is not None:
|
||||
self.score = marked_ans.status
|
||||
|
||||
new_callstate = UserAnswer.CallStateOptions.INCORRECTCALL
|
||||
if marked_ans.status == Answer.MarkOptions.CORRECT:
|
||||
self.callstate = UserAnswer.CallStateOptions.CORRECT
|
||||
new_callstate = UserAnswer.CallStateOptions.CORRECT
|
||||
mark = 2
|
||||
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
|
||||
self.callstate = UserAnswer.CallStateOptions.PARTIALCALL
|
||||
new_callstate = UserAnswer.CallStateOptions.PARTIALCALL
|
||||
mark = 1
|
||||
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
|
||||
self.callstate = UserAnswer.CallStateOptions.INCORRECTCALL
|
||||
new_callstate = UserAnswer.CallStateOptions.INCORRECTCALL
|
||||
mark = 0
|
||||
self.save()
|
||||
|
||||
if self.score != marked_ans.status or self.callstate != new_callstate:
|
||||
self.score = marked_ans.status
|
||||
self.callstate = new_callstate
|
||||
self.save()
|
||||
else:
|
||||
if self.score != Answer.MarkOptions.UNMARKED or self.callstate is not None:
|
||||
self.score = Answer.MarkOptions.UNMARKED
|
||||
self.callstate = None
|
||||
self.save()
|
||||
|
||||
return mark
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends 'generic/exam_scores_base.html' %}
|
||||
{% extends base_template|default:'generic/exam_scores_base.html' %}
|
||||
|
||||
{% block table_answers %}
|
||||
{% for question in questions %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends 'generic/exam_scores_base.html' %}
|
||||
{% extends base_template|default:'generic/exam_scores_base.html' %}
|
||||
|
||||
{% block table_answers %}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends 'generic/exam_scores_base.html' %}
|
||||
{% extends base_template|default:'generic/exam_scores_base.html' %}
|
||||
|
||||
{% block table_answers %}
|
||||
{% for question in questions %}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
We need to rework the scores pages for all of the app exams. we need to try to make sure that we keep all of the content that currently exists but display it in a more usable format. at the same time we need to optimise the request as for some of the app with large number of users this results in a huge number of sql queries. it may be sensible to split some of the page content into different sections that can be loaded via htmx partials (so that they are also reusable elsewhere).
|
||||
Remove the cache links such as "refresh the scores" if they are no longer required.
|
||||
|
||||
At the same time we need to have a look at how the scores are calculated, some of the apps currently use exam caching. if possible we want to avoid this, if not we want it to be fully transparent to the end user.
|
||||
Load the stats panels as htmx partials. please also add additional stats that would be useful and style it more nicely.
|
||||
|
||||
the anatomy app features a new marking mode mark2. we want to port a similar new marking mode to the rapids app. it should be styled in a similar way to the mark2 mode in anatomy and bring across any new features (that are applicable)
|
||||
|
||||
Both the candidate list and the answers table should allow filtering by candidates to allow them to be quickly viewed.
|
||||
Reference in New Issue
Block a user