diff --git a/anatomy/models.py b/anatomy/models.py index 026fcd3e..98df48d2 100644 --- a/anatomy/models.py +++ b/anatomy/models.py @@ -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 diff --git a/anatomy/templates/anatomy/exam_scores.html b/anatomy/templates/anatomy/exam_scores.html index 2a7f751e..bd6018a4 100644 --- a/anatomy/templates/anatomy/exam_scores.html +++ b/anatomy/templates/anatomy/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/generic/templates/generic/exam_scores_base.html b/generic/templates/generic/exam_scores_base.html index 66f7278d..995de8a5 100644 --- a/generic/templates/generic/exam_scores_base.html +++ b/generic/templates/generic/exam_scores_base.html @@ -85,57 +85,19 @@
Answers as a table
- +
-
-
- - - - - {% for cid in cids %} - {% comment %} {% endcomment %} - - {% endfor %} - - - - {% block table_answers %} - {% endblock table_answers %} - {% comment %} {% for question in questions %} - - - {% for cid in cids %} - {% with by_question|get_item:question|get_item:cid as ans_score %} - - {% endwith %} - {% endfor %} - - {% endfor %} {% endcomment %} - - - {% for cid in cids %} - - {% endfor %} - -
Candidate{{cid}} - - {% if cid|slice:":1" == "u" %} - - {% else %} - - - - {% endif %} - - {{cids_user_id_map|get_item:cid}}
Question {{forloop.counter}} - {% if question.normal %} - [N] - {% else %} - [A] - {% endif %} - {{ans_score.0}}
Score:{{user_scores|get_item:cid}}
+
+
+
+
+ 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 @@ +
+ + + + + {% for cid in cids %} + + {% endfor %} + + + {% block table_answers %} + {% endblock table_answers %} + + + {% for cid in cids %} + + {% endfor %} + +
Candidate + {% if cid|slice:":1" == "u" %} + + {% else %} + + {% endif %} + {{cids_user_id_map|get_item:cid}} +
Score:{{user_scores|get_item:cid}}
+
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