From 06e66ad52c0ada47c559a4afe43dbf8e5d1a929a Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 28 Apr 2025 12:15:31 +0100 Subject: [PATCH] shorts might now be functional? --- .../templates/generic/exam_scores_base.html | 2 +- generic/views.py | 108 ++++-- longs/templates/longs/mark_answer.html | 4 +- longs/views.py | 2 - rad/static/css/anatomy.css | 10 + rad/views.py | 193 +++++++--- shorts/forms.py | 17 +- .../migrations/0006_alter_useranswer_score.py | 19 + shorts/migrations/0007_exam_double_mark.py | 18 + ...eranswer_candidate_feedback_answermarks.py | 32 ++ shorts/models.py | 110 +++++- shorts/templates/shorts/exam_scores.html | 19 + shorts/templates/shorts/mark_answer.html | 118 ++++++ shorts/templates/shorts/mark_overview.html | 20 + .../shorts/mark_question_double_overview.html | 45 +++ .../shorts/mark_question_single_overview.html | 45 +++ shorts/urls.py | 10 + shorts/views.py | 363 +++++++++++------- 18 files changed, 901 insertions(+), 234 deletions(-) create mode 100644 shorts/migrations/0006_alter_useranswer_score.py create mode 100644 shorts/migrations/0007_exam_double_mark.py create mode 100644 shorts/migrations/0008_useranswer_candidate_feedback_answermarks.py create mode 100644 shorts/templates/shorts/exam_scores.html create mode 100644 shorts/templates/shorts/mark_answer.html create mode 100644 shorts/templates/shorts/mark_overview.html create mode 100644 shorts/templates/shorts/mark_question_double_overview.html create mode 100644 shorts/templates/shorts/mark_question_single_overview.html diff --git a/generic/templates/generic/exam_scores_base.html b/generic/templates/generic/exam_scores_base.html index e930a88e..e06fe7b7 100644 --- a/generic/templates/generic/exam_scores_base.html +++ b/generic/templates/generic/exam_scores_base.html @@ -43,7 +43,7 @@

Stats

Candidates: {{cids|length}}
Questions: {{question_number}}
- Available marks: {{max_score}}
+ Available marks: {{max_score}}
Mean: {{mean}}, Median {{median}}, Mode {{mode}}
Top score: {{exam.stats_max}} diff --git a/generic/views.py b/generic/views.py index 02e8c8fc..6fbe850d 100644 --- a/generic/views.py +++ b/generic/views.py @@ -571,39 +571,44 @@ class ExamViews(View, LoginRequiredMixin): if ( self.app_name == "rapids" - and not user.groups.filter(name="rapid_checker").exists() + and user.groups.filter(name="rapid_checker").exists() ): - return False + return True + if ( + self.app_name == "shorts" + and user.groups.filter(name="shorts").exists() + ): + return True if ( self.app_name == "anatomy" - and not user.groups.filter( - name__in=("anatomy_checker", "anatomy_marker") + and user.groups.filter( + name__in=("anatomy_checker", "anatomy_marker") ).exists() ): - return False + return True if ( self.app_name == "longs" - and not user.groups.filter( - name__in=("long_checker", "long_marker") + and user.groups.filter( + name__in=("long_checker", "long_marker") ).exists() ): - return False + return True if ( self.app_name == "physics" - and not user.groups.filter(name="physics_checker").exists() + and user.groups.filter(name="physics_checker").exists() ): - return False + return True if ( self.app_name == "sbas" - and not user.groups.filter(name="sba_checker").exists() + and user.groups.filter(name="sba_checker").exists() ): - return False + return True if ( self.app_name == "atlas" - and not user.groups.filter(name="casecollection_checker").exists() + and user.groups.filter(name="casecollection_checker").exists() ): - return False - return True + return True + return False def check_user_marker_access(self, user: User, exam_id: int = None): return self.check_user_access(user, exam_id, marker=True) @@ -1367,6 +1372,7 @@ class ExamViews(View, LoginRequiredMixin): app_exam_map = {} app_exam_map["rapids"] = cid_user.rapid_exams + app_exam_map["shorts"] = cid_user.shorts_exams app_exam_map["anatomy"] = cid_user.anatomy_exams app_exam_map["longs"] = cid_user.longs_exams app_exam_map["physics"] = cid_user.physics_exams @@ -1849,6 +1855,24 @@ class ExamViews(View, LoginRequiredMixin): ans.answer = "" elif answer["qidn"] == "2" and not ans.normal: ans.answer = posted_answer + case "shorts": + if not existing_answers: + if uid: + ans = self.UserAnswer( + answer=posted_answer, user=User.objects.get(id=uid) + ) + else: + ans = self.UserAnswer(answer=posted_answer, cid=cid) + + ans.question_id = answer["qid"] + ans.exam_id = eid + ans.score = None + else: + # Update an existing answer + # should never be more than one (famous last words) + ans = existing_answers[0] + ans.answer = posted_answer + ans.score = None case "anatomy": if not existing_answers: if uid: @@ -2275,15 +2299,7 @@ class ExamViews(View, LoginRequiredMixin): else: total_score = sum(answers_marks) - match self.app_name: - case "rapids" | "anatomy": - max_score = len(questions) * 2 - case "longs": - max_score = len(questions) * 8 - case "physics": - max_score = len(questions) * 5 - case _: - max_score = len(questions) + max_score = self.get_max_score(questions) #cid_user_exam = exam.cid_user_exam.filter(user_user=user).first() #print(user) @@ -2314,6 +2330,18 @@ class ExamViews(View, LoginRequiredMixin): template_context, ) + def get_max_score(self, questions): + match self.app_name: + case "rapids" | "anatomy": + max_score = len(questions) * 2 + case "longs": + max_score = len(questions) * 8 + case "physics" | "shorts": + max_score = len(questions) * 5 + case _: + max_score = len(questions) + return max_score + def exam_scores_all(self, request, pk): """The exam scores pages. Displays all user scores in a tabular format. @@ -2349,13 +2377,14 @@ class ExamViews(View, LoginRequiredMixin): cached_scores = True valid_cid_users = set(exam.valid_cid_users.all().values_list("cid", flat=True)) + print(valid_cid_users) valid_user_users = set(exam.valid_user_users.all().values_list("pk", flat=True)) if self.app_name in ("rapids"): user_answers_callstates = defaultdict(list) user_answers_callstates_counted = {} - if self.app_name in ("physics", "sbas", "longs"): + if self.app_name in ("physics", "sbas", "longs", "shorts"): cached_scores = False questions = exam.get_questions() else: @@ -2420,7 +2449,7 @@ class ExamViews(View, LoginRequiredMixin): 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"): + elif self.app_name in ("anatomy", "sbas", "longs", "shorts"): by_question[q][cid] = (ans, answer_score) else: zipped_ans_scores = zip(ans, answer_score) @@ -2442,13 +2471,13 @@ class ExamViews(View, LoginRequiredMixin): by_question[question][user] = ("Not answered", 3.0) user_answers_marks[user].append(3.0) - if self.app_name in ("rapids", "anatomy", "sbas", "longs"): + if self.app_name in ("physics",): user_scores[user] = sum( - [i for i in user_answers_marks[user] if i != "unmarked"] + [sum(i) for i in user_answers_marks[user] if i != "unmarked"] ) else: user_scores[user] = sum( - [sum(i) for i in user_answers_marks[user] if i != "unmarked"] + [i for i in user_answers_marks[user] if i != "unmarked" and i != None] ) if self.app_name == "rapids": @@ -2463,12 +2492,7 @@ class ExamViews(View, LoginRequiredMixin): # ignore scores of 0 for stats user_scores_list = [i for i in user_scores.values() if i > 0] - if self.app_name in ("rapids", "anatomy"): - max_score = question_number * 2 - elif self.app_name == "physics": - max_score = question_number * 5 - else: - max_score = question_number + max_score = self.get_max_score(questions) if len(user_scores_list) < 1: mean = 0 @@ -2842,6 +2866,7 @@ class CidUserExamView(CidManagerRequiredMixin, SingleTableMixin, FilterView): physics_exams = [(i.name, i.pk) for i in PhysicsExam.objects.filter(**filters)] rapid_exams = [(i.name, i.pk) for i in RapidsExam.objects.filter(**filters)] + shorts_exams = [(i.name, i.pk) for i in ShortsExam.objects.filter(**filters)] sba_exams = [(i.name, i.pk) for i in SbasExam.objects.filter(**filters)] longs_exams = [(i.name, i.pk) for i in LongsExam.objects.filter(**filters)] anatomy_exams = [(i.name, i.pk) for i in AnatomyExam.objects.filter(**filters)] @@ -2856,6 +2881,7 @@ class CidUserExamView(CidManagerRequiredMixin, SingleTableMixin, FilterView): context["physics_exams"] = physics_exams context["rapid_exams"] = rapid_exams + context["shorts_exams"] = shorts_exams context["sba_exams"] = sba_exams context["longs_exams"] = longs_exams context["anatomy_exams"] = anatomy_exams @@ -2880,6 +2906,7 @@ class CidUserView(CidManagerRequiredMixin, SingleTableMixin, FilterView): physics_exams = [(i.name, i.pk) for i in PhysicsExam.objects.filter(**filters)] rapid_exams = [(i.name, i.pk) for i in RapidsExam.objects.filter(**filters)] + shorts_exams = [(i.name, i.pk) for i in ShortsExam.objects.filter(**filters)] sba_exams = [(i.name, i.pk) for i in SbasExam.objects.filter(**filters)] longs_exams = [(i.name, i.pk) for i in LongsExam.objects.filter(**filters)] anatomy_exams = [(i.name, i.pk) for i in AnatomyExam.objects.filter(**filters)] @@ -2890,6 +2917,7 @@ class CidUserView(CidManagerRequiredMixin, SingleTableMixin, FilterView): context["physics_exams"] = physics_exams context["rapid_exams"] = rapid_exams + context["shorts_exams"] = shorts_exams context["sba_exams"] = sba_exams context["longs_exams"] = longs_exams context["anatomy_exams"] = anatomy_exams @@ -3343,6 +3371,7 @@ def manage_cid_users(request): sba_exams = json.loads(request.POST.get("sba_exams")) longs_exams = json.loads(request.POST.get("longs_exams")) anatomy_exams = json.loads(request.POST.get("anatomy_exams")) + shorts_exams = json.loads(request.POST.get("shorts_exams")) casecollection_exams = json.loads(request.POST.get("casecollection_exams")) cid_groups = json.loads(request.POST.get("cid_groups")) @@ -3404,6 +3433,7 @@ def manage_cid_users(request): c.sba_exams.clear() c.longs_exams.clear() c.anatomy_exams.clear() + c.shorts_exams.clear() c.casecollection_exams.clear() return JsonResponse({"status": "success"}) @@ -3420,6 +3450,8 @@ def manage_cid_users(request): c.longs_exams.add(exam) for exam in anatomy_exams: c.anatomy_exams.add(exam) + for exam in shorts_exams: + c.shorts_exams.add(exam) for exam in casecollection_exams: c.casecollection_exams.add(exam) c.save() @@ -3433,6 +3465,7 @@ def manage_cid_users(request): c.sba_exams.set(sba_exams) c.longs_exams.set(longs_exams) c.anatomy_exams.set(anatomy_exams) + c.shorts_exams.set(shorts_exams) c.casecollection_exams.set(casecollection_exams) c.save() return JsonResponse({"status": "success"}) @@ -3479,6 +3512,8 @@ def manage_cid_users(request): c.longs_exams.set(longs_exams) if anatomy_exams: c.anatomy_exams.set(anatomy_exams) + if shorts_exams: + c.shorts_exams.set(shorts_exams) if casecollection_exams: c.casecollection_exams.set(casecollection_exams) c.save() @@ -3501,6 +3536,8 @@ def manage_cid_users(request): c.longs_exams.set(longs_exams) if anatomy_exams: c.anatomy_exams.set(anatomy_exams) + if shorts_exams: + c.shorts_exams.set(shorts_exams) if casecollection_exams: c.casecollection_exams.set(casecollection_exams) c.save() @@ -3832,6 +3869,7 @@ class ExamCollectionClone(CreateView, AuthorRequiredMixin): GROUP_TYPES = ( ("Rapid Exams", "rapids_exams", RapidsExam), + ("Short Exams", "shorts_exams", ShortsExam), ("Long Exams", "longs_exams", LongsExam), ("SBA Exams", "sbas_exams", SbasExam), ("Physic Exams", "physics_exams", PhysicsExam), diff --git a/longs/templates/longs/mark_answer.html b/longs/templates/longs/mark_answer.html index 08b07823..374b52b8 100644 --- a/longs/templates/longs/mark_answer.html +++ b/longs/templates/longs/mark_answer.html @@ -45,8 +45,8 @@ Set final score
{% endif %} - Return to question - overview, marking + Return to question + overview, marking overview
{% if previous_answer_id %} Previous diff --git a/longs/views.py b/longs/views.py index a536ac31..28e8f524 100755 --- a/longs/views.py +++ b/longs/views.py @@ -679,7 +679,6 @@ def mark_answer(request, exam_id, question_number, answer_id, override=False): ) answer_list.sort() - print(answer_list) previous_answer_id = False next_answer_id = False @@ -690,7 +689,6 @@ def mark_answer(request, exam_id, question_number, answer_id, override=False): elif answer_list[-1] == answer_id: previous_answer_id = answer_list[-2] else: - print(answer_list.index(answer_id)) next_answer_id = answer_list[answer_list.index(answer_id) + 1] previous_answer_id = answer_list[answer_list.index(answer_id) - 1] diff --git a/rad/static/css/anatomy.css b/rad/static/css/anatomy.css index c846e2af..4bb99a47 100644 --- a/rad/static/css/anatomy.css +++ b/rad/static/css/anatomy.css @@ -1327,4 +1327,14 @@ a, a:link { span#user-id { opacity: 50%; float: right; +} + +.marking-link { + color: #8600789e; + text-decoration: none; + text-transform: uppercase; +} +.marking-link:hover { + color: #3282b8; + text-decoration: underline; } \ No newline at end of file diff --git a/rad/views.py b/rad/views.py index f8b889f1..34e3e3a0 100644 --- a/rad/views.py +++ b/rad/views.py @@ -7,7 +7,12 @@ from atlas.models import CaseCollection, CidReportAnswer, Case as AtlasCase from generic.decorators import user_is_cid_user_manager from generic.filters import UserUserFilter from generic.tables import UserUserTable -from generic.views import CidManagerRequiredMixin, RedirectMixin, get_question_and_content_type, get_user_exams +from generic.views import ( + CidManagerRequiredMixin, + RedirectMixin, + get_question_and_content_type, + get_user_exams, +) from django.core.exceptions import PermissionDenied, ObjectDoesNotExist from django.shortcuts import render, get_object_or_404, redirect from django.views.decorators.csrf import csrf_exempt @@ -63,10 +68,19 @@ from sbas.models import Exam as SbasExam from anatomy.views import GenericExamViews as AnatomyExamViews from rapids.views import GenericExamViews as RapidsExamsViews -from longs.views import GenericExamViews as LongsExamViews +from shorts.views import GenericExamViews as ShortsExamsViews +from longs.views import GenericExamViews as LongsExamsViews from generic.forms import QuestionNoteForm -from generic.models import CidUser, CimarCase, ExamCollection, QuestionNote, Supervisor, UserGrades, UserProfile +from generic.models import ( + CidUser, + CimarCase, + ExamCollection, + QuestionNote, + Supervisor, + UserGrades, + UserProfile, +) from django_filters.views import FilterView @@ -82,31 +96,44 @@ from loguru import logger import psutil + def feedback_checker(user): return user.groups.filter(name="feedback_checker").exists() + @user_passes_test(lambda u: u.is_superuser) def server(request): - return render(request, "server.html") + @user_is_cid_user_manager def people(request): return render(request, "people.html", {}) + @login_required def profile(request): user = request.user return render(request, "profile.html", {"user": user}) + def index(request): collections = [] if request.user.is_authenticated: collections = ExamCollection.objects.filter(archive=False, author=request.user) - - rcr_assessor = request.user.groups.filter(Q(name="rcr_radiology_assessor") | Q(name="rcr_oncology_assessor")| Q(name="rcr_assessor")).exists() - return render(request, "index.html", {"rcr_assessor": rcr_assessor, "collections": collections}) + + rcr_assessor = request.user.groups.filter( + Q(name="rcr_radiology_assessor") + | Q(name="rcr_oncology_assessor") + | Q(name="rcr_assessor") + ).exists() + return render( + request, + "index.html", + {"rcr_assessor": rcr_assessor, "collections": collections}, + ) + @user_is_cid_user_manager def account_profile(request, slug): @@ -151,6 +178,7 @@ def cid_results(request, cid): }, ) + @user_passes_test(lambda u: u.is_superuser) def user_scores_admin(request, user_id): try: @@ -283,11 +311,15 @@ def active_exams(request, cid=None, passcode=None, based=True): rapid_exams = RapidsExamsViews.active_exams( request, False, cid=cid, passcode=passcode ) - long_exams = LongsExamViews.active_exams(request, False, cid=cid, passcode=passcode) + short_exams = ShortsExamsViews.active_exams( + request, False, cid=cid, passcode=passcode + ) + long_exams = LongsExamsViews.active_exams(request, False, cid=cid, passcode=passcode) exams = [] exams.extend(anatomy_exams) exams.extend(rapid_exams) + exams.extend(short_exams) exams.extend(long_exams) active_exams["exams"] = exams @@ -318,7 +350,10 @@ def exam_submit(request): elif exam_type == "rapid": return RapidsExamsViews.post_exam_answers(request) elif exam_type == "long": - return LongsExamViews.post_exam_answers(request) + return LongsExamsViews.post_exam_answers(request) + elif exam_type == "short": + return ShortsExamsViews.post_exam_answers(request) + return JsonResponse({"success": False, "error": "Invalid exam type"}) except ObjectDoesNotExist: return JsonResponse({"success": False, "error": "Invalid data"}) @@ -512,12 +547,14 @@ def view_feedback(request): def privacy_view(request): return render(request, "privacy.html") + def cimar(request): if "link_id" in request.GET: link_id = request.GET["link_id"] return render(request, "cimar.html", {"link_id": link_id}) return render(request, "cimar.html") + def about_view(request): return render(request, "about.html") @@ -528,12 +565,17 @@ def stats(request): anatomy_exam_count = AnatomyExam.objects.filter(archive=False).count() anatomy_question_count = AnatomyQuestion.objects.filter().count() - return render(request, "stats.html" - , {"collection_count": collection_count, - "case_count": case_count, - "anatomy_exam_count": anatomy_exam_count, - "anatomy_question_count": anatomy_question_count - }) + return render( + request, + "stats.html", + { + "collection_count": collection_count, + "case_count": case_count, + "anatomy_exam_count": anatomy_exam_count, + "anatomy_question_count": anatomy_question_count, + }, + ) + # HTTP Error 400 def page_not_found(request, exception): @@ -545,7 +587,6 @@ def page_not_found(request, exception): def page_forbidden(request, exception): - response = render(request, "403.html", context={"exception": exception}) response.status_code = 403 @@ -575,11 +616,11 @@ class UserListTableView(CidManagerRequiredMixin, SingleTableMixin, FilterView): filterset_class = UserUserFilter - #table_pagination = {"per_page": 5} + # table_pagination = {"per_page": 5} def get_paginate_by(self, table_data) -> int | None: return 100 - #return super().get_paginate_by(table_data) + # return super().get_paginate_by(table_data) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -620,7 +661,12 @@ class DeleteUserView(CidManagerRequiredMixin, DeleteView): class UpdateUserView(CidManagerRequiredMixin, UpdateView): model = User - fields = ["first_name", "last_name", "email", "is_active"] # Keep listing whatever fields + fields = [ + "first_name", + "last_name", + "email", + "is_active", + ] # Keep listing whatever fields # the combined UserProfile and User exposes. template_name = "user_update.html" slug_field = "username" @@ -649,21 +695,31 @@ class UpdateUserProfileAdminView(CidManagerRequiredMixin, UpdateView): # No need for reverse_lazy here, because it's called inside the method return reverse(view_name, kwargs={"slug": self.object.username}) + class UpdateUserProfileView(UpdateView): model = UserProfile fields = [ "supervisor", "grade", "registration_number", - #"peninsula_trainee", + # "peninsula_trainee", ] # Keep listing whatever fields template_name = "user_update_profile.html" slug_field = "user__username" slug_url_kwarg = "slug" def dispatch(self, request, *args, **kwargs): - if request.user.is_superuser or request.user.groups.filter(name="cid_user_manager").exists(): - self.fields = ["supervisor", "grade", "registration_number", "site", "peninsula_trainee"] + if ( + request.user.is_superuser + or request.user.groups.filter(name="cid_user_manager").exists() + ): + self.fields = [ + "supervisor", + "grade", + "registration_number", + "site", + "peninsula_trainee", + ] # Check permissions for the request.user here elif request.user != self.get_object().user: raise Http404 @@ -680,18 +736,22 @@ class UpdateUserProfileView(UpdateView): form = context["form"] if "supervisor" in form.fields: - #form.fields["supervisor"].queryset = Supervisor.objects.all() + # form.fields["supervisor"].queryset = Supervisor.objects.all() form.fields["supervisor"] = forms.ModelChoiceField( - Supervisor.objects.all().order_by("name"), required=False, widget=autocomplete.ModelSelect2(url='generic:supervisor-autocomplete') - ) + Supervisor.objects.all().order_by("name"), + required=False, + widget=autocomplete.ModelSelect2(url="generic:supervisor-autocomplete"), + ) return context - def get_success_url(self): if "redirect" in self.request.GET: return self.request.GET["redirect"] - if self.request.user.is_superuser or self.request.user.groups.filter(name="cid_user_manager").exists(): + if ( + self.request.user.is_superuser + or self.request.user.groups.filter(name="cid_user_manager").exists() + ): view_name = "account_profile" # No need for reverse_lazy here, because it's called inside the method return reverse(view_name, kwargs={"slug": self.object.username}) @@ -699,7 +759,6 @@ class UpdateUserProfileView(UpdateView): return reverse("profile") - # class UpdateUser(TemplateView): # # user_form_class = UserForm @@ -747,12 +806,12 @@ def accounts_check_users(request): return + @user_is_cid_user_manager def accounts_bulk_create_check(request): if not request.method == "POST": return HttpResponse("Invalid request") - user = {} user["email"] = request.POST.get("email") @@ -791,10 +850,12 @@ def accounts_bulk_create_check(request): errors.append("Supervisor name does not match") if errors: - return HttpResponse(format_html("{}", "
".join(errors))) + return HttpResponse( + format_html("{}", "
".join(errors)) + ) return HttpResponse(format_html("{}", "
".join(info))) - + @user_is_cid_user_manager def accounts_bulk_create(request): @@ -864,7 +925,7 @@ def accounts_bulk_create(request): except Exception as error: error_text = ( error_text - + f"

Error creating user: { user['email'] }
{error}

" + + f"

Error creating user: {user['email']}
{error}

" ) # No need to try and create a profile if we can't create a user continue @@ -899,7 +960,7 @@ def accounts_bulk_create(request): new_user.delete() error_text = ( error_text - + f"

Error creating user profile: { user['email'] }
{error} (user not created)

" + + f"

Error creating user profile: {user['email']}
{error} (user not created)

" ) except Exception as error: @@ -939,34 +1000,42 @@ def request_cid_details(request): def cimar_study_dicom_json(request, uuid="c9417b53-87aa-4c40-aca0-3fa34c225536"): with CimarAPI(request.user.userprofile.cimar_sid) as api: print(api.sid) - #studies = api.list_studies() - #test_study = studies["studies"][0] + # studies = api.list_studies() + # test_study = studies["studies"][0] - #schema = api.get_study_schema(test_study) + # schema = api.get_study_schema(test_study) schema = api.get_study_schema_by_uuid("c9417b53-87aa-4c40-aca0-3fa34c225536") return JsonResponse(api.get_study_dicom_json(schema)) -def cimar_study_viewer(request, uuid="c9417b53-87aa-4c40-aca0-3fa34c225536"): - case = get_object_or_404(CimarCase, uuid=uuid) - - viewer_link = case.viewer_link + f"?sid={request.user.userprofile.cimar_sid}" - return render(request, "cimar_embed.html", {"viewer_link": viewer_link}) +def cimar_study_viewer(request, uuid="c9417b53-87aa-4c40-aca0-3fa34c225536"): + case = get_object_or_404(CimarCase, uuid=uuid) + + viewer_link = case.viewer_link + f"?sid={request.user.userprofile.cimar_sid}" + return render(request, "cimar_embed.html", {"viewer_link": viewer_link}) + def cimar_study_viewer_embed(request, uuid="c9417b53-87aa-4c40-aca0-3fa34c225536"): + case = get_object_or_404(CimarCase, uuid=uuid) + + viewer_link = case.viewer_link + f"?sid={request.user.userprofile.cimar_sid}" + return render(request, "cimar_embed.html#embed", {"viewer_link": viewer_link}) - case = get_object_or_404(CimarCase, uuid=uuid) - - viewer_link = case.viewer_link + f"?sid={request.user.userprofile.cimar_sid}" - return render(request, "cimar_embed.html#embed", {"viewer_link": viewer_link}) def cimar_study_series_block(request, uuid): + case = get_object_or_404(CimarCase, uuid=uuid) + + return render( + request, + "cimar_series.html", + { + "series_data": case.series_data["series"], + "cimar_sid": request.user.userprofile.cimar_sid, + }, + ) - case = get_object_or_404(CimarCase, uuid=uuid) - - return render(request, "cimar_series.html", {"series_data": case.series_data["series"], "cimar_sid": request.user.userprofile.cimar_sid}) def cimar_status(request): login_status = CimarAPI(request.user.userprofile.cimar_sid).check_login_status() @@ -974,20 +1043,26 @@ def cimar_status(request): if login_status: return HttpResponse("") else: - return render(request, "cimar_login.html#login-block", {"login_status": login_status, "cimar_sid": request.user.userprofile.cimar_sid}) + return render( + request, + "cimar_login.html#login-block", + { + "login_status": login_status, + "cimar_sid": request.user.userprofile.cimar_sid, + }, + ) + def cimar_logout(request): CimarAPI(request.user.userprofile.cimar_sid).logout() return HttpResponse("Logged out") -def cimar_login(request): +def cimar_login(request): if request.htmx: username = request.POST.get("username") password = request.POST.get("password") - - if not username and not password: return HttpResponse("Please fill in login details") @@ -999,12 +1074,20 @@ def cimar_login(request): except InvalidCredentials: return HttpResponse("Invalid login details") - return HttpResponse("Logged in") else: try: - login_status = CimarAPI(request.user.userprofile.cimar_sid).check_login_status() + login_status = CimarAPI( + request.user.userprofile.cimar_sid + ).check_login_status() except NoSession: login_status = False - return render(request, "cimar_login.html", {"login_status": login_status, "cimar_sid": request.user.userprofile.cimar_sid}) \ No newline at end of file + return render( + request, + "cimar_login.html", + { + "login_status": login_status, + "cimar_sid": request.user.userprofile.cimar_sid, + }, + ) diff --git a/shorts/forms.py b/shorts/forms.py index 34173e6e..75f7ea05 100644 --- a/shorts/forms.py +++ b/shorts/forms.py @@ -11,6 +11,7 @@ from generic.forms import ExamAuthorFormMixin, ExamFormMixin, ExamGroupsFormMixi from shorts.models import ( Abnormality, + AnswerMarks, Examination, Question, QuestionFinding, @@ -255,4 +256,18 @@ class QuestionFindingForm(ModelForm): super(QuestionFindingForm, self).__init__(*args, **kwargs) - ModelForm.__init__(self, *args, **kwargs) \ No newline at end of file + ModelForm.__init__(self, *args, **kwargs) + +class MarkQuestionSingleForm(ModelForm): + class Meta: + model = UserAnswer + fields = ["score", "candidate_feedback"] + + widgets = { + "candidate_feedback": Textarea(attrs={"rows": 3}), + } + +class MarkQuestionDoubleForm(ModelForm): + class Meta: + model = AnswerMarks + fields = ["score", "mark_reason"] \ No newline at end of file diff --git a/shorts/migrations/0006_alter_useranswer_score.py b/shorts/migrations/0006_alter_useranswer_score.py new file mode 100644 index 00000000..61d66d8e --- /dev/null +++ b/shorts/migrations/0006_alter_useranswer_score.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.4 on 2025-04-14 12:55 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0005_remove_question_abnormality_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='useranswer', + name='score', + field=models.IntegerField(blank=True, default=0, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)]), + ), + ] diff --git a/shorts/migrations/0007_exam_double_mark.py b/shorts/migrations/0007_exam_double_mark.py new file mode 100644 index 00000000..7a3c4a4e --- /dev/null +++ b/shorts/migrations/0007_exam_double_mark.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2025-04-28 08:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0006_alter_useranswer_score'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='double_mark', + field=models.BooleanField(default=False, help_text='Defines if an exam is expected to be double marked'), + ), + ] diff --git a/shorts/migrations/0008_useranswer_candidate_feedback_answermarks.py b/shorts/migrations/0008_useranswer_candidate_feedback_answermarks.py new file mode 100644 index 00000000..5fa07043 --- /dev/null +++ b/shorts/migrations/0008_useranswer_candidate_feedback_answermarks.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.4 on 2025-04-28 08:55 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0007_exam_double_mark'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='candidate_feedback', + field=models.TextField(blank=True, help_text='Feedback for the candidate', null=True), + ), + migrations.CreateModel( + name='AnswerMarks', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.IntegerField(blank=True, default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])), + ('mark_reason', models.TextField(blank=True, help_text='Reason for the given mark - not visible to candidates', null=True)), + ('marker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shorts_answers', to=settings.AUTH_USER_MODEL)), + ('user_answer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mark', to='shorts.useranswer')), + ], + ), + ] diff --git a/shorts/models.py b/shorts/models.py index a2f68996..1ec2abdd 100644 --- a/shorts/models.py +++ b/shorts/models.py @@ -30,6 +30,7 @@ from django.utils.html import mark_safe from rapids.models import Abnormality, Examination, Region from helpers.images import get_image_hash, image_as_base64 +from django.utils.html import format_html def image_directory_path(instance, filename): @@ -63,6 +64,12 @@ class Question(QuestionBase): related_name="shorts_authored_questions", ) + def __str__(self): + return "{}: {}".format( + self.id, + self.history, + ) + def get_app_name(self): return "shorts" @@ -116,6 +123,52 @@ class Question(QuestionBase): def get_best_sample_answer(self): return self.sample_answers.filter(score__gt=0).order_by("-score").first() + def get_exams(self): + e = self.exams.all().values_list("name", flat=True) + exams = ", ".join(e) + return exams + + def get_unmarked_user_answer_string(self, exam_pk: int = None): + unmarked_answers = self.get_unmarked_user_answers(exam_pk) + + if not unmarked_answers: + return "No answers to mark" + return format_html( + "{} answer unmarked: {}".format( + len(unmarked_answers), ", ".join(unmarked_answers) + ) + ) + + def get_unmarked_user_answers(self, exam_pk: int | None = None, marker=None): + if exam_pk is None: + user_answer_queryset = self.cid_user_answers.all() + else: + user_answer_queryset = self.cid_user_answers.filter(exam__id=exam_pk) + + unmarked_answers = user_answer_queryset.filter(score__isnull=True) + + + return unmarked_answers + + + def get_unmarked_user_answer_count(self, exam_pk=None, marker=None): + if exam_pk is None: + return self.cid_user_answers.all().count() + + return len(self.get_unmarked_user_answers(exam_pk, marker=marker)) + + def get_user_answers(self, exam_pk=None, include_normal=True): + if exam_pk is None: + queryset = self.cid_user_answers.all() + else: + queryset = self.cid_user_answers.filter(exam__id=exam_pk) + + if not include_normal: + queryset = queryset.filter(normal=False) + + user_answers = set([i.answer for i in queryset]) + + return user_answers class QuestionImage(models.Model): @@ -230,6 +283,10 @@ class Exam(ExamBase): related_name="shorts_exam_markers", ) + double_mark = models.BooleanField( + default=False, help_text="Defines if an exam is expected to be double marked" + ) + valid_cid_users = models.ManyToManyField( CidUser, blank=True, related_name="shorts_exams" ) @@ -271,20 +328,16 @@ class Exam(ExamBase): return "shorts" def get_exam_json(self, based=True): - # TODO - - return {} questions = self.get_questions() exam_questions = defaultdict(dict) exam_order = [] - q: Rapid + q: Question for q in questions: exam_order.append(q.id) - # Loop through rapidimage associations images = [] feedback_images = [] image_titles = [] @@ -311,7 +364,7 @@ class Exam(ExamBase): "images": images, # "feedback_image": [], # "annotations": [str(q.image_annotations)], - "type": "rapid", + "type": "short", } if not self.exam_mode: @@ -331,9 +384,9 @@ class Exam(ExamBase): # exam_questions[q.id]["feedback_image"] = feedback_images exam_json = { - "eid": "rapid/{}".format(self.id), + "eid": "short/{}".format(self.id), "cached": False, - "exam_type": "rapid", + "exam_type": "short", "exam_name": self.name, "exam_mode": self.exam_mode, "exam_order": exam_order, @@ -343,6 +396,7 @@ class Exam(ExamBase): if self.time_limit: exam_json["exam_time"] = self.time_limit + print(exam_json) return exam_json def get_absolute_url(self): @@ -373,8 +427,13 @@ class UserAnswer(UserAnswerBase): Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True ) + # If score is null then the answer is unmarked score = models.IntegerField( - default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)] + default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)], null=True + ) + + candidate_feedback = models.TextField( + null=True, blank=True, help_text="Feedback for the candidate" ) class CallStateOptions(models.TextChoices): @@ -426,6 +485,15 @@ class UserAnswer(UserAnswerBase): def get_answer_score(self, cached=True): return self.score + def get_answer_string(self): + return self.answer + + def is_marked(self): + if self.score is None: + return False + else: + return True + class QuestionFinding(FindingBase): question = models.ForeignKey( @@ -439,3 +507,27 @@ class QuestionFinding(FindingBase): def __str__(self) -> str: findings = self.findings.all().values_list("name") return f"Findings: {self.question.id}/{findings}/{self.description}" + +class AnswerMarks(models.Model): + # Same as in UserAnswer except null is not allowed + score = models.IntegerField( + default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)] + ) + + mark_reason = models.TextField( + null=True, + blank=True, + help_text="Reason for the given mark - not visible to candidates", + ) + + marker = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name="shorts_answers", on_delete=models.CASCADE + ) + + user_answer = models.ForeignKey( + UserAnswer, + related_name="mark", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) diff --git a/shorts/templates/shorts/exam_scores.html b/shorts/templates/shorts/exam_scores.html new file mode 100644 index 00000000..76c90e41 --- /dev/null +++ b/shorts/templates/shorts/exam_scores.html @@ -0,0 +1,19 @@ +{% extends 'generic/exam_scores_base.html' %} + +{% block table_answers %} + {% for question in questions %} + +
Question {{forloop.counter}} + + {% comment %} {% for cid in cids %} + {{ans_by_question|get_item:question|get_item:cid}} + {% endfor %} {% endcomment %} + {% for cid in cids %} + {% with by_question|get_item:question|get_item:cid as ans_score %} + {{ans_score.1|default_if_none:"Unmarked"}} + {% endwith %} + {% endfor %} + + {% endfor %} +{% endblock table_answers %} + diff --git a/shorts/templates/shorts/mark_answer.html b/shorts/templates/shorts/mark_answer.html new file mode 100644 index 00000000..4c68ac7d --- /dev/null +++ b/shorts/templates/shorts/mark_answer.html @@ -0,0 +1,118 @@ +{% extends 'shorts/exams.html' %} +{% load crispy_forms_tags %} + +{% block content %} + View + Edit + {% if request.user.is_superuser %} + Admin + Edit Admin + Edit (user answer) + {% endif %} +

Marking question {{question_details.current}} of {{question_details.total}}

+ + {% if discrepancy_form %} + + {% endif %} + + {% if not next_unmarked_id and not unmarked %} + + {% endif %} + + Marking Candidate: {{answer.get_candidate_masked}} ({{unmarked|length}} answer(s) left to mark) +
+ Question Details +

History: {{ question.history }}

+

Region: {{ question.get_regions }}

+

Examination: {{ question.get_examinations }}

+

Abnormality: {{ question.get_abnormalities }}

+
Images: + {% for image in question.images.all %} + + Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}: +
+
+ {% endfor %} +
+

Feedback: {{ question.feedback }}

+
+
+
+ Marking Guidance + {{ question.marking_guidance | safe}} +
+
+ User answer +
+
+
{{answer.answer}}
+
+
+
+
+
+
{% csrf_token %} + + {{ form|crispy }} + + {% if next_unmarked_id %} + + + {% else %} + {% if not unmarked %} + Success! Marking question complete. Return to marking + overview + {% endif %} + {% endif %} +
+
+ {% if previous_answer_id %} + Previous + candidate + {% endif %} + {% if next_answer_id %} + Next candidate + {% endif %} +{% endblock %} diff --git a/shorts/templates/shorts/mark_overview.html b/shorts/templates/shorts/mark_overview.html new file mode 100644 index 00000000..9d9348e5 --- /dev/null +++ b/shorts/templates/shorts/mark_overview.html @@ -0,0 +1,20 @@ +{% extends 'shorts/exams.html' %} + +{% block content %} +
+

Marking exam: {{ exam }}

+ You can start marking from a particular question by clicking on it below. + +
+ +
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/shorts/templates/shorts/mark_question_double_overview.html b/shorts/templates/shorts/mark_question_double_overview.html new file mode 100644 index 00000000..2aa6e2a7 --- /dev/null +++ b/shorts/templates/shorts/mark_question_double_overview.html @@ -0,0 +1,45 @@ +{% extends 'longs/exams.html' %} + +{% block content %} + Question -> View + Edit Admin + Edit +

Marking question {{question_details.current}} of {{question_details.total}}

+
+ + {% if not unmarked_count %} + + {% else %} + + {% endif %} + Answers: + +
+ {% if question_details.current > 1 %} + Previous + {% endif %} + {% if question_details.current >= question_details.total %} + {% else %} + Next + {% endif %} +
+
+ +{% endblock %} diff --git a/shorts/templates/shorts/mark_question_single_overview.html b/shorts/templates/shorts/mark_question_single_overview.html new file mode 100644 index 00000000..fc63e77b --- /dev/null +++ b/shorts/templates/shorts/mark_question_single_overview.html @@ -0,0 +1,45 @@ +{% extends 'shorts/exams.html' %} + +{% block content %} +Question -> View +Edit Admin + Edit +

Marking question {{question_details.current}} of {{question_details.total}}

+
+ + {% if not unmarked_count %} + + {% else %} + + + {% endif %} + Answers: + +
+ {% if question_details.current > 1 %} + Previous + {% endif %} + {% if question_details.current >= question_details.total %} + {% else %} + Next + {% endif %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/shorts/urls.py b/shorts/urls.py index 3f22c4ec..74f27aaa 100644 --- a/shorts/urls.py +++ b/shorts/urls.py @@ -48,6 +48,16 @@ urlpatterns = [ ), #path("answer//confirm", views.confirm_answer, name="confirm_answer"), #path("answer//delete", views.delete_answer, name="delete_answer"), + path( + "exam////mark", + views.mark_answer, + name="mark_answer", + ), + path( + "exam////mark_override", + views.mark_answer_override, + name="mark_answer_override", + ), path("exam///mark", views.mark, name="mark"), path("exam///mark/all", views.mark_all, name="mark_all"), path( diff --git a/shorts/views.py b/shorts/views.py index cbf70159..c6ebd813 100644 --- a/shorts/views.py +++ b/shorts/views.py @@ -5,7 +5,11 @@ from django import forms # from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.models import User -from django.core.exceptions import PermissionDenied +from django.core.exceptions import ( + ObjectDoesNotExist, + PermissionDenied, + ViewDoesNotExist, +) from django.forms.models import model_to_dict @@ -41,6 +45,7 @@ from atlas.models import Finding, Structure, Condition from shorts.decorators import user_is_author_or_shorts_checker from .models import ( + AnswerMarks, Exam, Question, QuestionFinding, @@ -55,6 +60,8 @@ from .forms import ( ExamAuthorForm, ExamGroupsForm, ExamMarkerForm, + MarkQuestionDoubleForm, + MarkQuestionSingleForm, QuestionFindingForm, QuestionForm, QuestionSampleAnswerForm, @@ -342,11 +349,206 @@ def mark_all(request, exam_pk, sk): def mark_review(request, exam_pk, sk): return mark(request, exam_pk, sk, unmarked_exam_answers_only=False, review=True) +def mark_answer_override(request, exam_id, question_number, cid): + return mark_answer(request, exam_id, question_number, cid, override=True) + + +def mark_answer(request, exam_id, question_number, answer_id, override=False): + exam = get_object_or_404(Exam, pk=exam_id) + + if not GenericExamViews.check_user_marker_access(request.user, exam_id): + raise PermissionDenied + + questions = exam.get_questions() + + n = question_number + + question_details = { + "total": len(questions), + "current": n + 1, + } + + try: + question = questions[question_number] + except IndexError: + raise Http404("Exam question does not exist") + + try: + answer = question.cid_user_answers.get(pk=answer_id) + except ObjectDoesNotExist: + raise Http404("User answer does not exist") + + answer_list = list( + question.cid_user_answers.filter(exam__id=exam_id).values_list("id", flat=True) + ) + + answer_list.sort() + + previous_answer_id = False + next_answer_id = False + + if len(answer_list) > 1: + if answer_list[0] == answer_id: + next_answer_id = answer_list[1] + elif answer_list[-1] == answer_id: + previous_answer_id = answer_list[-2] + else: + print(answer_list.index(answer_id)) + next_answer_id = answer_list[answer_list.index(answer_id) + 1] + previous_answer_id = answer_list[answer_list.index(answer_id) - 1] + + try: + if exam.double_mark: + unmarked = question.get_unmarked_user_answers( + exam_pk=exam.id, marker=request.user + ) + else: + unmarked = question.get_unmarked_user_answers(exam_pk=exam.id) + next_unmarked_id = unmarked[0].id + if next_unmarked_id == answer_id: + next_unmarked_id = unmarked[1].id + except IndexError: + next_unmarked_id = False + + # We use different forms if the exam should be single or double marked + + if not exam.double_mark or ( + request.method == "POST" and request.POST["form_id"] == "discrepancy_form" + ): + if request.method == "POST": + form = MarkQuestionSingleForm(request.POST) + + if form.is_valid(): + # If skip button is pressed skip the question without marking + # Skip is problematic if we skip to the next unmarked answer + # if "skip" in request.POST: + # return redirect("longs:mark_answer", pk=exam_id, sk=question_number, cid=next_unmarked_id) + + # Extract score from form and save it to the object + answer.score = form.cleaned_data["score"] + answer.candidate_feedback = form.cleaned_data["candidate_feedback"] + answer.save() + + if "next" in request.POST: + return redirect( + "shorts:mark_answer", + exam_id=exam_id, + question_number=question_number, + answer_id=next_unmarked_id, + ) + if "save" in request.POST: + return redirect( + "shorts:mark_answer", + exam_id=exam_id, + question_number=question_number, + answer_id=answer_id, + ) + # elif "previous" in request.POST: + # return redirect("longs:mark", pk=exam_id, sk=n - 1) + + else: + form = MarkQuestionSingleForm( + initial={ + "score": answer.score, + "candidate_feedback": answer.candidate_feedback, + } + ) + + else: + if request.method == "POST": + form = MarkQuestionDoubleForm(request.POST) + + if form.is_valid(): + # If skip button is pressed skip the question without marking + # Skip is problematic if we skip to the next unmarked answer + # if "skip" in request.POST: + # return redirect("longs:mark_answer", pk=exam_id, sk=question_number, cid=next_unmarked_id) + + mark_object = AnswerMarks.objects.get_or_create( + user_answer=answer, marker=request.user + )[0] + # Extract score from form and save it to the object + mark_object.score = form.cleaned_data["score"] + mark_object.mark_reason = form.cleaned_data["mark_reason"] + mark_object.candidate_feedback = form.cleaned_data["candidate_feedback"] + mark_object.marker = request.user + mark_object.save() + + # Form is saved, check if we have two marks (and if they match) + if answer.mark.count() == 2: + # if they do automatically update teh scoer + marks = set(answer.mark.values_list("score", flat=True)) + if len(marks) == 1: + answer.score = marks.pop() + else: + answer.score = "" + answer.save() + + if "next" in request.POST: + return redirect( + "shorts:mark_answer", + exam_id=exam_id, + question_number=question_number, + answer_id=next_unmarked_id, + ) + if "save" in request.POST: + return redirect( + "shorts:mark_answer", + exam_id=exam_id, + question_number=question_number, + answer_id=answer_id, + ) + # elif "previous" in request.POST: + # return redirect("longs:mark", pk=exam_id, sk=n - 1) + + else: + try: + mark_object = AnswerMarks.objects.get( + user_answer=answer, marker=request.user + ) + form = MarkQuestionDoubleForm( + initial={ + "score": mark_object.score, + "mark_reason": mark_object.mark_reason, + "candidate_feedback": mark_object.candidate_feedback, + } + ) + except AnswerMarks.DoesNotExist: + form = MarkQuestionDoubleForm() + + discrepancy_form = False + if answer.mark.count() == 2 or override: + # if they do automatically update teh scoer + marks = set(answer.mark.values_list("score", flat=True)) + if len(marks) > 1 or override: + discrepancy_form = MarkQuestionSingleForm( + initial={ + "score": answer.score, + "candidate_feedback": answer.candidate_feedback, + } + ) + + return render( + request, + "shorts/mark_answer.html", + { + "exam": exam, + "form": form, + "discrepancy_form": discrepancy_form, + "answer": answer, + "question": question, + "question_details": question_details, + "next_unmarked_id": next_unmarked_id, + "unmarked": unmarked, + "previous_answer_id": previous_answer_id, + "next_answer_id": next_answer_id, + "answer_id": answer_id, + }, + ) # @user_passes_test(user_is_admin, login_url="/accounts/login") @login_required -def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): - return HttpResponse("Not implemented") +def mark(request, exam_pk, sk, unmarked_exam_answers_only=True): exam = get_object_or_404(Exam, pk=exam_pk) if not GenericExamViews.check_user_marker_access(request.user, exam_pk): @@ -355,12 +557,6 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): if not exam.exam_mode: raise Http404("Packet not in exam mode") - mark_url = "shorts:mark" - if review: - mark_url = "shorts:mark_review" - elif not unmarked_exam_answers_only: - mark_url = "shorts:mark_all" - questions = exam.get_questions() n = sk @@ -375,129 +571,38 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): except IndexError: raise Http404("Exam question does not exist") - if request.method == "POST": + user_answers = question.cid_user_answers.filter(exam__id=exam_pk) - form = MarkQuestionForm(request.POST) - # ******************************* - # TODO: convert to JSON request - # ******************************* - if form.is_valid(): - # If skip button is pressed skip the question without marking - if "skip" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) + unmarked_count = user_answers.filter(score__isnull=True).count() - cd = form.cleaned_data - - marked_answers = json.loads(cd.get("marked_answers")) - - # This should probably use json (or something else...) - # ajax..... - to_run = ( - ("correct", Answer.MarkOptions.CORRECT), - ("half-correct", Answer.MarkOptions.HALF_MARK), - ("incorrect", Answer.MarkOptions.INCORRECT), - ) - - for status_string, status in to_run: - for ans in marked_answers[status_string]: - ans = ans.strip() - if ans == "": - continue - - a = Answer.objects.filter( - answer__iexact=ans, question_id=question.pk - ).first() - - if a is None: - a = Answer() - a.question_id = question.pk - a.answer = ans - - a.status = status - a.save() - - if "next" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) - elif "previous" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n - 1) - - # Reset user answers (relies on the functional javascript) - unmarked_user_answers = set() + if exam.double_mark: + marker_unmarked_count = question.get_unmarked_user_answer_count( + exam.pk, marker=request.user + ) + return render( + request, + "shorts/mark_question_double_overview.html", + { + "exam": exam, + "question": question, + "question_details": question_details, + "user_answers": user_answers, + "unmarked_count": unmarked_count, + "marker_unmarked_count": marker_unmarked_count, + }, + ) else: - form = MarkQuestionForm() - - # answers_dict = {} - - # for ans in question.answers.all(): - # answers_dict[ans.answer_compare] = ans - - correct_answers = [] - half_mark_answers = [] - incorrect_answers = [] - review_user_answers = [] - unmarked_answers_bool = False - unmarked_user_answers = [] - # this could be improved - if question.normal: - incorrect_answers = question.get_user_answers(exam_pk=exam_pk) - if "" in incorrect_answers: - incorrect_answers.remove("") - if "normal" in incorrect_answers: - incorrect_answers.remove("normal") - else: - if review: - unmarked_user_answers = question.get_user_answers( - exam_pk=exam_pk, include_normal=False - ) - elif unmarked_exam_answers_only: - unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk) - else: - unmarked_user_answers = question.get_unmarked_user_answers() - - if not unmarked_exam_answers_only: - correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT) - half_mark_answers = question.answers.filter( - status=Answer.MarkOptions.HALF_MARK - ) - incorrect_answers = question.answers.filter( - status=Answer.MarkOptions.INCORRECT - ) - - if review: - for ans in unmarked_user_answers: - # duplicated from user answer model.... - marked_ans = question.answers.filter(answer_compare__iexact=ans).first() - mark = "unmarked" - if marked_ans is not None: - if marked_ans.status == Answer.MarkOptions.CORRECT: - mark = "correct" - elif marked_ans.status == Answer.MarkOptions.HALF_MARK: - mark = "half-correct" - elif marked_ans.status == Answer.MarkOptions.INCORRECT: - mark = "incorrect" - if mark == "unmarked": - unmarked_answers_bool = True - review_user_answers.append((ans, mark)) - - return render( - request, - "shorts/mark.html", - { - "exam": exam, - "form": form, - "review": review, - "question": question, - "question_number": n, - "question_details": question_details, - "unmarked_answers_bool": unmarked_answers_bool, - "user_answers": unmarked_user_answers, - "review_user_answers": review_user_answers, - "correct_answers": correct_answers, - "half_mark_answers": half_mark_answers, - "incorrect_answers": incorrect_answers, - "unmarked_exam_answers_only": unmarked_exam_answers_only, - }, - ) + return render( + request, + "shorts/mark_question_single_overview.html", + { + "exam": exam, + "question": question, + "question_details": question_details, + "user_answers": user_answers, + "unmarked_count": unmarked_count, + }, + ) class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView):