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 %}
+
+
+ {% if discrepancy_form %}
+
+
+ This answer has discrepant scores
+ You can set the overall score here.
+
+
+
+ Marker scores and reason
+
+ {% for mark_object in answer.mark.all %}
+ - {{mark_object.marker}}: {{mark_object.score}}
+ {{mark_object.mark_reason}}
+
+ {% endfor %}
+
+
+
+ {% 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
+
+
+
+
+ {% 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 %}
+
All answers completely marked.
+ {% else %}
+
{{unmarked_count}} answer(s) incompletely marked.
+ You have {{marker_unmarked_count}} / {{user_answers.count}} answers left to mark.
+
+ {% endif %}
+ Answers:
+
+ {% for answer in user_answers %}
+ -
+ {{answer.cid}}:
+ Score {{answer.get_answer_score}} [Markers: {{answer.get_markers}}] [Scores: {{answer.get_mark_scores}}]
+ {% if answer.discrepant_answers %}
+ This answer has discrepant scores.
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {% 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 %}
+
No answers to mark.
+ {% else %}
+
{{unmarked_count}} answer(s) to mark.
+
+ {% endif %}
+ Answers:
+
+ {% for answer in user_answers %}
+ -
+ {{answer.get_candidate_masked}}:
+ {% if answer.is_marked %}
+ Score {{answer.get_answer_score}}
+ {% else %}
+ Unmarked
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {% 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):