-
-
-
- | Candidate |
- {% for cid in cids %}
- {% comment %} {{cid}} | {% endcomment %}
-
-
- {% if cid|slice:":1" == "u" %}
-
- {% else %}
-
-
-
- {% endif %}
-
- {{cids_user_id_map|get_item:cid}} |
- {% endfor %}
-
-
-
- {% block table_answers %}
- {% endblock table_answers %}
- {% comment %} {% for question in questions %}
-
- | Question {{forloop.counter}}
- {% if question.normal %}
- [N]
- {% else %}
- [A]
- {% endif %}
- |
- {% for cid in cids %}
- {% with by_question|get_item:question|get_item:cid as ans_score %}
- {{ans_score.0}} |
- {% endwith %}
- {% endfor %}
-
- {% endfor %} {% endcomment %}
-
- | Score: |
- {% for cid in cids %}
- {{user_scores|get_item:cid}} |
- {% endfor %}
-
-
+
+
+
+
+ Loading...
+
+
Loading answers table...
+
diff --git a/generic/templates/generic/exam_scores_partial_base.html b/generic/templates/generic/exam_scores_partial_base.html
new file mode 100644
index 00000000..87bcc12d
--- /dev/null
+++ b/generic/templates/generic/exam_scores_partial_base.html
@@ -0,0 +1,27 @@
+
diff --git a/generic/views.py b/generic/views.py
index 0584e982..58224dae 100644
--- a/generic/views.py
+++ b/generic/views.py
@@ -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":
diff --git a/longs/templates/longs/exam_scores.html b/longs/templates/longs/exam_scores.html
index 3b6bb180..ff02b5b4 100644
--- a/longs/templates/longs/exam_scores.html
+++ b/longs/templates/longs/exam_scores.html
@@ -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 %}
diff --git a/physics/templates/physics/exam_scores.html b/physics/templates/physics/exam_scores.html
index 8718bee5..ae8db920 100644
--- a/physics/templates/physics/exam_scores.html
+++ b/physics/templates/physics/exam_scores.html
@@ -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 %}
diff --git a/rapids/models.py b/rapids/models.py
index ebd45498..8312b15a 100644
--- a/rapids/models.py
+++ b/rapids/models.py
@@ -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
diff --git a/rapids/templates/rapids/exam_scores.html b/rapids/templates/rapids/exam_scores.html
index bff599ad..faefe914 100644
--- a/rapids/templates/rapids/exam_scores.html
+++ b/rapids/templates/rapids/exam_scores.html
@@ -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 %}
diff --git a/sbas/templates/sbas/exam_scores.html b/sbas/templates/sbas/exam_scores.html
index 3217f19c..e0372c38 100644
--- a/sbas/templates/sbas/exam_scores.html
+++ b/sbas/templates/sbas/exam_scores.html
@@ -1,4 +1,4 @@
-{% extends 'generic/exam_scores_base.html' %}
+{% extends base_template|default:'generic/exam_scores_base.html' %}
{% block table_answers %}
diff --git a/shorts/templates/shorts/exam_scores.html b/shorts/templates/shorts/exam_scores.html
index 76c90e41..6aeffefb 100644
--- a/shorts/templates/shorts/exam_scores.html
+++ b/shorts/templates/shorts/exam_scores.html
@@ -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 %}
diff --git a/todo b/todo
index f1ffd3ee..02e5f17f 100644
--- a/todo
+++ b/todo
@@ -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.
\ No newline at end of file
+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.
\ No newline at end of file