From f454d5a08d28adaa9ef79e6b8393c68b27f2db0c Mon Sep 17 00:00:00 2001 From: Ross Date: Sun, 26 Jun 2022 19:01:48 +0100 Subject: [PATCH] . --- .../0065_exam_exam_results_emailed.py | 18 ++ ...0036_ciduserexam_results_emailed_status.py | 18 ++ generic/models.py | 102 +++++++++- generic/views.py | 185 ++++++++++++++---- .../0063_exam_exam_results_emailed.py | 18 ++ .../0025_exam_exam_results_emailed.py | 18 ++ pytest.ini | 3 +- rad/urls.py | 117 +++++++---- rad/views.py | 6 +- .../0059_ciduseranswer_callstate.py | 18 ++ .../0060_exam_exam_results_emailed.py | 18 ++ rapids/models.py | 29 ++- rapids/templates/rapids/exam_scores_new.html | 41 +++- rapids/tests/test_urls.py | 25 ++- rapids/urls.py | 4 + .../0015_exam_exam_results_emailed.py | 18 ++ 16 files changed, 544 insertions(+), 94 deletions(-) create mode 100644 anatomy/migrations/0065_exam_exam_results_emailed.py create mode 100644 generic/migrations/0036_ciduserexam_results_emailed_status.py create mode 100644 longs/migrations/0063_exam_exam_results_emailed.py create mode 100644 physics/migrations/0025_exam_exam_results_emailed.py create mode 100644 rapids/migrations/0059_ciduseranswer_callstate.py create mode 100644 rapids/migrations/0060_exam_exam_results_emailed.py create mode 100644 sbas/migrations/0015_exam_exam_results_emailed.py diff --git a/anatomy/migrations/0065_exam_exam_results_emailed.py b/anatomy/migrations/0065_exam_exam_results_emailed.py new file mode 100644 index 00000000..277dec65 --- /dev/null +++ b/anatomy/migrations/0065_exam_exam_results_emailed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-26 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0064_auto_20220521_1020'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='exam_results_emailed', + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/generic/migrations/0036_ciduserexam_results_emailed_status.py b/generic/migrations/0036_ciduserexam_results_emailed_status.py new file mode 100644 index 00000000..3d6bf201 --- /dev/null +++ b/generic/migrations/0036_ciduserexam_results_emailed_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-26 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generic', '0035_userprofile_grade'), + ] + + operations = [ + migrations.AddField( + model_name='ciduserexam', + name='results_emailed_status', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/generic/models.py b/generic/models.py index a6f0cbe2..0eca08ad 100644 --- a/generic/models.py +++ b/generic/models.py @@ -145,6 +145,8 @@ class ExamBase(models.Model): # Is this actually used? cid_users = GenericRelation("generic.CidUserExam") + exam_results_emailed = models.DateTimeField(default=None, null=True) + class Meta: abstract = True @@ -153,11 +155,14 @@ class ExamBase(models.Model): self.recreate_json = recreate_json super().save(*args, **kwargs) - def get_exam_stats(self): - text = f"""{self.name} -{"-"*len(self.name)} -- Candidates: {int(self.stats_candidates)} + def get_exam_stats(self, name=True): + text = f"""- Candidates: {int(self.stats_candidates)} - Mean: {self.stats_mean:.2f} Median: {self.stats_median} Mode: {self.stats_mode} [Min: {self.stats_min} / Max: {self.stats_max}] +""" + if name: + text = f"""{self.name} +{"-"*len(self.name)} +{text} """ return text @@ -223,7 +228,7 @@ class ExamBase(models.Model): if user_user is not None: c = CidUserExam.objects.filter( - content_type=content_type, object_id=self.pk, user_user=cid_user + content_type=content_type, object_id=self.pk, user_user=user_user ).first() if c: return c @@ -270,12 +275,93 @@ class ExamBase(models.Model): ) def get_cid_user_score(self, cid_user): - c = str(cid_user) + c = "c/"+str(cid_user) if c in self.user_scores: return self.user_scores[c] else: return False + def generate_user_report(self, user): + exam_text = [f"Candidate {user.first_name} [{user.email}]"] + + exam_name = self.name + + exam_text.append(f"{'='*len(exam_name)}\n{exam_name}\n{'='*len(exam_name)}") + + try: + user_score = self.user_scores["u/"+str(user.pk)]["score"] + + if self.app_name == "rapids": + callstate = self.user_scores["u/"+str(user.pk)]["callstates"] + except KeyError: + user_score = "Score not generated" + + try: + percentage = ( + f" ({user_score / int(self.stats_max_possible) * 100:.2f}%)" + ) + except TypeError: + percentage = "" + + + exam_text.append( + f"""Results\n{'-'*len('Results')} +# Score: {user_score} / {int(self.stats_max_possible)}{percentage} +""" + ) + stats = self.get_exam_stats(name=False) + exam_text.append(f"Stats\n{'-'*len('Stats')}\n{stats}") + + + exam_text.append( + f"Answers breakdown: {callstate}" + ) + + msg = "\n\n".join(exam_text) + + return msg + + def email_user_results(self, user, resend=False, additional_emails=[]): + print("email_user_reuslts:", user) + + #if self.results_email_sent and not resend: + # return False, "Already sent." + + # Get a list of taken exams + msg = self.generate_user_report(user) + + print(msg) + + if not user.email: + return False, "User has no email" + + emails = [user.email] + + supervisor_email = user.userprofile.supervisor_email + + if supervisor_email: + emails.append(supervisor_email) + + + if additional_emails: + emails.extend(additional_emails) + + try: + send_mail( + f"{self.name} results", + msg, + "no-reply@penracourses.org.uk", + emails, + fail_silently=False, + # html_message=html_msg, + ) + except SMTPException as e: + return False, e + + #self.results_email_sent = True + #self.save() + return True, "" + class NoteType(models.Model): note_type = models.CharField(max_length=200) @@ -396,7 +482,7 @@ class CidUser(models.Model): for exam in exams: try: - user_score = exam.user_scores[str(self.cid)] + user_score = exam.user_scores["c/"+str(self.cid)] except KeyError: user_score = "Score not generated" t = exam.get_exam_stats() @@ -523,6 +609,8 @@ class CidUserExam(models.Model): manual_submission = models.BooleanField(default=False) + results_emailed_status = models.CharField(max_length=255, blank=True) + class CidUserGroup(models.Model): name = models.CharField(blank=True, max_length=50) diff --git a/generic/views.py b/generic/views.py index b03f4703..fcca04ee 100644 --- a/generic/views.py +++ b/generic/views.py @@ -1,4 +1,4 @@ -from collections import defaultdict +from collections import defaultdict, Counter import statistics import threading from dal import autocomplete @@ -294,7 +294,9 @@ class ExamViews(View, LoginRequiredMixin): # exam_group = group_map[self.app_name] class ExamFilter(FilterSet): - sort_order = OrderingFilter(fields=(("name", "name"), ("exam_mode", "exam_mode"))) + sort_order = OrderingFilter( + fields=(("name", "name"), ("exam_mode", "exam_mode")) + ) class Meta: model = exam @@ -529,16 +531,15 @@ class ExamViews(View, LoginRequiredMixin): def exam_list(self, request, all=False): if not self.check_user_access(request.user): - #raise PermissionDenied - exam_list = self.Exam.objects.filter(author__id=request.user.id).order_by("name") + # raise PermissionDenied + exam_list = self.Exam.objects.filter(author__id=request.user.id).order_by( + "name" + ) else: exam_list = self.Exam.objects.all().order_by("name") if not all: - exams = exam_list.filter(exam_mode=True, archive=False).order_by( - "name" - ) - + exams = exam_list.filter(exam_mode=True, archive=False).order_by("name") marking = False @@ -556,6 +557,90 @@ class ExamViews(View, LoginRequiredMixin): }, ) + @method_decorator(login_required) + def exam_report_email_status(self, request, exam_id): + exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=exam_id) + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, exam_id): + raise PermissionDenied + + users = [i[2:] for i in exam.user_scores if i.startswith("u/")] + + status = {} + for u in users: + user = User.objects.get(pk=u) + user_exam = exam.get_or_create_cid_user_exam(user_user=user) + status[user.username] = user_exam.results_emailed_status + + return JsonResponse(status) + + + @method_decorator(login_required) + def exam_report_email(self, request, exam_id): + exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=exam_id) + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, exam_id): + raise PermissionDenied + + + # We only need to send emails to those who have scores + # (who should be in exam.user_scores) + users = [i[2:] for i in exam.user_scores if i.startswith("u/")] + + time = timezone.now() + + email_results = {} + for u in users: + user = User.objects.get(pk=u) + email_results[user.username] = exam.email_user_results(user) + + user_exam = exam.get_or_create_cid_user_exam(user_user=user) + user_exam.results_emailed_status = json.dumps(email_results[user.username]) + f" {time}" + user_exam.save() + + exam.exam_results_emailed = time + exam.save() + + return JsonResponse(email_results) + + @method_decorator(login_required) + def exam_user_report_email(self, request, exam_id, user_id): + # print(Exam.objects.all()) + # exams = Exam.objects.all() + # print("test", Exam.objects.all().get(id=pk)) + exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=exam_id) + + u = get_object_or_404(User, pk=user_id) + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, exam_id): + raise PermissionDenied + + res = exam.email_user_results(u) + + return HttpResponse(res) + + @method_decorator(login_required) + def exam_user_report(self, request, exam_id, user_id): + # print(Exam.objects.all()) + # exams = Exam.objects.all() + # print("test", Exam.objects.all().get(id=pk)) + exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=exam_id) + + u = get_object_or_404(User, pk=user_id) + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, exam_id): + raise PermissionDenied + + res = exam.generate_user_report(u) + print(res) + + return HttpResponse(res) + + @method_decorator(login_required) def exam_overview(self, request, pk): # print(Exam.objects.all()) @@ -563,6 +648,7 @@ class ExamViews(View, LoginRequiredMixin): # print("test", Exam.objects.all().get(id=pk)) exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=pk) + if request.user not in exam.author.all(): if not self.check_user_access(request.user, pk): raise PermissionDenied @@ -1396,6 +1482,10 @@ class ExamViews(View, LoginRequiredMixin): by_question = defaultdict(dict) unmarked = set() + if self.app_name in ("rapids"): + user_answers_callstates = defaultdict(list) + user_answers_callstates_counted = {} + if self.app_name in ("physics", "sbas"): questions = exam.exam_questions.all() else: @@ -1408,15 +1498,18 @@ class ExamViews(View, LoginRequiredMixin): cids = set() + cids_user_id_map = {} + # Loop through all candidates for cid_user_answer in cid_user_answers: # Convoluted (probably...) if cid_user_answer.user is None: cid = cid_user_answer.cid # cid_passcodes[cid] = cid_user_answer.passcode - cids.add(cid) + cids.add("c/"+cid) else: - cid = f"u/{cid_user_answer.user.username}" + cid = f"u/{cid_user_answer.user.pk}" + cids_user_id_map[cid] = cid_user_answer.user.username cids.add(cid) s = cid_user_answer @@ -1439,9 +1532,15 @@ class ExamViews(View, LoginRequiredMixin): unmarked.add(index) user_answers[cid].append(ans) user_answers_marks[cid].append(answer_score) - # user_answers_and_marks[cid].append((ans, answer_score)) - if self.app_name in ("rapids", "anatomy", "sbas"): + if self.app_name == "rapids": + callstate = s.get_answer_callstate() + print(cid, callstate) + by_question[q][cid] = (ans, answer_score, callstate) + user_answers_callstates[cid].append(callstate) + print(user_answers_callstates) + print("CID:::",cid, user_answers_callstates[cid]) + elif self.app_name in ("rapids", "anatomy", "sbas"): by_question[q][cid] = (ans, answer_score) else: zipped_ans_scores = zip(ans, answer_score) @@ -1513,33 +1612,53 @@ class ExamViews(View, LoginRequiredMixin): exam.stats_graph = fig_html - exam.user_scores = user_scores + user_data = {} + for u in cids: + #uid = cids_user_id_map[u] + user_data[u] = { + "score": user_scores[u], + "normalised_score": user_scores_normalised[u], + } + + if self.app_name == "rapids": + counted_callstates = dict(Counter(user_answers_callstates[u])) + user_data[u]["callstates"] = str(counted_callstates) + user_answers_callstates_counted[u] = counted_callstates + + print(user_data) + + exam.user_scores = user_data exam.save() + template_variables = { + "cids": sorted(cids), + # "cid_passcodes": cid_passcodes, + "exam": exam, + "unmarked": unmarked, + "questions": questions, + "by_question": by_question, + # "user_answers": dict(user_answers), + # "user_answers_marks": dict(user_answers_marks), + "user_scores": user_scores, + "user_scores_normalised": user_scores_normalised, + # "user_scores_list": user_scores_list, + # "user_names": user_names, + # "user_answers_and_marks": user_answers_and_marks, + "max_score": max_score, + "mean": mean, + "median": median, + "mode": mode, + "plot": fig_html, + } + + if self.app_name == "rapids": + template_variables["user_answers_callstates"] = user_answers_callstates_counted + return render( request, f"{self.app_name}/exam_scores_new.html", - { - "cids": sorted(cids), - # "cid_passcodes": cid_passcodes, - "exam": exam, - "unmarked": unmarked, - "questions": questions, - "by_question": by_question, - # "user_answers": dict(user_answers), - # "user_answers_marks": dict(user_answers_marks), - "user_scores": user_scores, - "user_scores_normalised": user_scores_normalised, - # "user_scores_list": user_scores_list, - # "user_names": user_names, - # "user_answers_and_marks": user_answers_and_marks, - "max_score": max_score, - "mean": mean, - "median": median, - "mode": mode, - "plot": fig_html, - }, + template_variables, ) diff --git a/longs/migrations/0063_exam_exam_results_emailed.py b/longs/migrations/0063_exam_exam_results_emailed.py new file mode 100644 index 00000000..90314481 --- /dev/null +++ b/longs/migrations/0063_exam_exam_results_emailed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-26 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('longs', '0062_auto_20220521_2135'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='exam_results_emailed', + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/physics/migrations/0025_exam_exam_results_emailed.py b/physics/migrations/0025_exam_exam_results_emailed.py new file mode 100644 index 00000000..8087b105 --- /dev/null +++ b/physics/migrations/0025_exam_exam_results_emailed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-26 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('physics', '0024_alter_exam_cid_user_groups'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='exam_results_emailed', + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/pytest.ini b/pytest.ini index fde73665..31bba4c7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,4 @@ [pytest] DJANGO_SETTINGS_MODULE=rad.settings -python_files = tests.py test_*.py *_tests.py \ No newline at end of file +python_files = tests.py test_*.py *_tests.py +addopts = --nomigrations \ No newline at end of file diff --git a/rad/urls.py b/rad/urls.py index 8cb9625d..2a48dacd 100644 --- a/rad/urls.py +++ b/rad/urls.py @@ -23,39 +23,54 @@ from django.views.generic import TemplateView from . import views - from rapids import views as rapid_views from anatomy import views as anatomy_views from longs import views as long_views from rest_framework import routers -from django.conf.urls import ( - handler400, handler403, handler404, handler500 -) +from django.conf.urls import handler400, handler403, handler404, handler500 router = routers.DefaultRouter() -router.register(r'rapids/exams', rapid_views.ExamViewSet, basename="rapid-exam") -router.register(r'anatomy/exams', anatomy_views.ExamViewSet, basename="anatomy-exam") -router.register(r'longs/exams', long_views.ExamViewSet, basename="long-exam") -router.register(r'rapids/answers', rapid_views.QuestionAnswerViewSet, basename="rapid-question-answers") -router.register(r'rapids/user_answer', rapid_views.UserAnswerViewSet, basename="rapid-question-answers") -router.register(r'rapids', rapid_views.RapidViewSet, basename="rapid") -#router.register(r'rapids/laterality', rapid_views.RapidLateralityViewSet, basename="rapid") +router.register(r"rapids/exams", rapid_views.ExamViewSet, basename="rapid-exam") +router.register(r"anatomy/exams", anatomy_views.ExamViewSet, basename="anatomy-exam") +router.register(r"longs/exams", long_views.ExamViewSet, basename="long-exam") +router.register( + r"rapids/answers", + rapid_views.QuestionAnswerViewSet, + basename="rapid-question-answers", +) +router.register( + r"rapids/user_answer", + rapid_views.UserAnswerViewSet, + basename="rapid-question-answers", +) +router.register(r"rapids", rapid_views.RapidViewSet, basename="rapid") +# router.register(r'rapids/laterality', rapid_views.RapidLateralityViewSet, basename="rapid") urlpatterns = [ path("admin/", admin.site.urls, name="admin"), path("anatomy/", include("anatomy.urls"), name="anatomy"), path("physics/", include("physics.urls"), name="physics"), - #path("sbas/", include("sbas.urls"), name="sbas"), + # path("sbas/", include("sbas.urls"), name="sbas"), path("rapids/", include("rapids.urls"), name="rapids"), path("longs/", include("longs.urls"), name="longs"), path("sbas/", include("sbas.urls"), name="sbas"), path("generic/", include("generic.urls"), name="generic"), path("atlas/", include("atlas.urls"), name="atlas"), - path("accounts/bulk_create/", views.accounts_bulk_create, name="accounts_bulk_create"), - path("accounts/update//", views.UpdateUserView.as_view(), name="account_update"), - path("accounts/update_profile//", views.UpdateUserProfileView.as_view(), name="account_profile_update"), + path( + "accounts/bulk_create/", views.accounts_bulk_create, name="accounts_bulk_create" + ), + path( + "accounts/update//", + views.UpdateUserView.as_view(), + name="account_update", + ), + path( + "accounts/update_profile//", + views.UpdateUserProfileView.as_view(), + name="account_profile_update", + ), path("accounts/", views.UserListView.as_view(), name="accounts_list"), path("accounts/", include("django.contrib.auth.urls")), path("accounts/profile", views.profile, name="profile"), @@ -68,44 +83,60 @@ urlpatterns = [ path("cid/", views.cid_selector, name="cid_selector"), # Global url that registers RTS compatible exams path("exam/json/", views.active_exams, name="active_exams"), - path("exam/json//", views.active_exams, name="active_exams_cid"), + path( + "exam/json//", + views.active_exams, + name="active_exams_cid", + ), path("exam/json/unbased", views.active_exams_unbased, name="active_exams_unbased"), path("exam/submit", views.exam_submit, name="global_exam_answers_submit"), - #path('', include('generic.urls')), - path("feedback//", views.AddQuestionNote.as_view(), name="feedback_create"), - path("feedback/note//complete", views.feedback_mark_complete, name="feedback_mark_complete"), - path("feedback/note//delete", views.feedback_note_delete, name="feedback_note_delete"), - #path("feedback/", views.AddQuestionNote.as_view(), name="feedback"), - path("feedback/answer", views.answer_suggestion_submit, name="answer_suggestion_submit"), + # path('', include('generic.urls')), + path( + "feedback//", + views.AddQuestionNote.as_view(), + name="feedback_create", + ), + path( + "feedback/note//complete", + views.feedback_mark_complete, + name="feedback_mark_complete", + ), + path( + "feedback/note//delete", + views.feedback_note_delete, + name="feedback_note_delete", + ), + # path("feedback/", views.AddQuestionNote.as_view(), name="feedback"), + path( + "feedback/answer", + views.answer_suggestion_submit, + name="answer_suggestion_submit", + ), path("feedback/answer_submit", views.answer_submit, name="answer_submit"), - path("feedback/answer/confirm", views.answer_suggestion_confirm, name="answer_suggestion_confirm"), + path( + "feedback/answer/confirm", + views.answer_suggestion_confirm, + name="answer_suggestion_confirm", + ), path("feedback/view", views.view_feedback, name="view_feedback"), - - - path('api/', include(router.urls)), - path('api-auth/', include('rest_framework.urls')), - - path('tinymce/', include('tinymce.urls')), - - #path('select2/', include('select2.urls')), - + path("api/", include(router.urls)), + path("api-auth/", include("rest_framework.urls")), + path("tinymce/", include("tinymce.urls")), + # path('select2/', include('select2.urls')), ] -#handler400 = 'my_app.views.bad_request' -handler403 = 'rad.views.page_forbidden' -handler404 = 'rad.views.page_not_found' -handler500 = 'rad.views.server_error' -#handler500 = 'my_app.views.server_error' +# handler400 = 'my_app.views.bad_request' +handler403 = "rad.views.page_forbidden" +handler404 = "rad.views.page_not_found" +handler500 = "rad.views.server_error" +# handler500 = 'my_app.views.server_error' urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, - document_root=settings.MEDIA_ROOT) - urlpatterns += static(settings.STATIC_URL, - document_root=settings.STATIC_ROOT) - urlpatterns += static("/rts/", - document_root="rts") + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static("/rts/", document_root="rts") import debug_toolbar urlpatterns = [ diff --git a/rad/views.py b/rad/views.py index 83b3648c..375d6b9a 100644 --- a/rad/views.py +++ b/rad/views.py @@ -493,7 +493,7 @@ def view_feedback(request): def page_not_found(request, exception): response = render(request, "404.html", {}) - # response.status_code = 404 + response.status_code = 404 return response @@ -501,7 +501,7 @@ def page_not_found(request, exception): def page_forbidden(request, exception): response = render(request, "403.html", {}) - # response.status_code = 404 + response.status_code = 403 return response @@ -509,7 +509,7 @@ def page_forbidden(request, exception): def server_error(request): response = render(request, "500.html", {}) - # response.status_code = 404 + response.status_code = 500 return response diff --git a/rapids/migrations/0059_ciduseranswer_callstate.py b/rapids/migrations/0059_ciduseranswer_callstate.py new file mode 100644 index 00000000..c8ba4b6b --- /dev/null +++ b/rapids/migrations/0059_ciduseranswer_callstate.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-20 21:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rapids', '0058_auto_20220521_1020'), + ] + + operations = [ + migrations.AddField( + model_name='ciduseranswer', + name='callstate', + field=models.CharField(blank=True, choices=[('C', 'Correct Call'), ('O', 'Overcall'), ('U', 'Undercall'), ('W', 'Incorrect Call'), ('P', 'Partial Call')], max_length=1, null=True), + ), + ] diff --git a/rapids/migrations/0060_exam_exam_results_emailed.py b/rapids/migrations/0060_exam_exam_results_emailed.py new file mode 100644 index 00000000..0ae6ee52 --- /dev/null +++ b/rapids/migrations/0060_exam_exam_results_emailed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-26 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rapids', '0059_ciduseranswer_callstate'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='exam_results_emailed', + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/rapids/models.py b/rapids/models.py index 6f0643a5..b786b6b6 100644 --- a/rapids/models.py +++ b/rapids/models.py @@ -775,6 +775,17 @@ class CidUserAnswer(models.Model): blank=True, ) + class CallStateOptions(models.TextChoices): + CORRECT = "C", _("Correct Call") + OVERCALL = "O", _("Overcall") + UNDERCALL = "U", _("Undercall") + INCORRECTCALL = "W", _("Incorrect Call") + PARTIALCALL = "P", _("Partial Call") + + callstate = models.CharField( + max_length=1, choices=CallStateOptions.choices, blank=True, null=True + ) + def __str__(self): name = self.cid @@ -823,6 +834,9 @@ class CidUserAnswer(models.Model): def get_absolute_url(self): return reverse("rapids:user_answer_view", kwargs={"pk": self.pk}) + def get_answer_callstate(self): + return self.callstate.label + def get_answer_score(self, cached=True): if cached and self.score: print("CACHED") @@ -840,10 +854,20 @@ class CidUserAnswer(models.Model): # First step we check that the normal/abnormal states match if q.normal != self.normal: + if q.normal: + self.callstate = CidUserAnswer.CallStateOptions.OVERCALL + else: + self.callstate = CidUserAnswer.CallStateOptions.UNDERCALL + + self.score = Answer.MarkOptions.INCORRECT + self.save() # If they don't match the answer is wrong (score is 0) return 0 # If both are normal full marks elif q.normal and self.normal: + self.callstate = CidUserAnswer.CallStateOptions.CORRECT + self.score = Answer.MarkOptions.CORRECT + self.save() return 2 # Then compare answer strings (as per anatomy questions) @@ -864,13 +888,16 @@ class CidUserAnswer(models.Model): mark = "unmarked" if marked_ans is not None: self.score = marked_ans.status - self.save() if marked_ans.status == Answer.MarkOptions.CORRECT: + self.callstate = CidUserAnswer.CallStateOptions.CORRECT mark = 2 elif marked_ans.status == Answer.MarkOptions.HALF_MARK: + self.callstate = CidUserAnswer.CallStateOptions.PARTIALCALL mark = 1 elif marked_ans.status == Answer.MarkOptions.INCORRECT: + self.callstate = CidUserAnswer.CallStateOptions.INCORRECTCALL mark = 0 + self.save() return mark diff --git a/rapids/templates/rapids/exam_scores_new.html b/rapids/templates/rapids/exam_scores_new.html index e211c950..e05bf994 100644 --- a/rapids/templates/rapids/exam_scores_new.html +++ b/rapids/templates/rapids/exam_scores_new.html @@ -34,9 +34,9 @@ {% for cid in cids %} {% if cid|slice:":1" == "u" %} - {{cid}} + {{cid}} {% else %} - {{cid}} + {{cid}} {% endif %} {{user_scores|get_item:cid}} {{user_scores_normalised|get_item:cid}} @@ -68,7 +68,7 @@ {% for cid in cids %} {% with by_question|get_item:question|get_item:cid as ans_score %} - {{ans_score.0}} + {{ans_score.0}} {% endwith %} {% endfor %} @@ -81,6 +81,41 @@ + Call breakdown +
    + {% for user, breakdown in user_answers_callstates.items %} +
  • + {{user}} - {{breakdown}} +
  • + {% endfor %} +
+ +
+ Email results + User results emailed: {{exam.exam_results_emailed|default:"Never"}}
+ + +
+ + + {% endblock %} diff --git a/rapids/tests/test_urls.py b/rapids/tests/test_urls.py index a03f2483..cde6b9f0 100644 --- a/rapids/tests/test_urls.py +++ b/rapids/tests/test_urls.py @@ -36,11 +36,11 @@ def create_superuser(db, django_user_model): @pytest.fixture(params=EXAM_VIEWS) def set_up_exams(request, django_user_model, create_superuser): basic_user = django_user_model.objects.create_user( - "user1", "user@user.net", "pass2" + "basicuser", "user@user.net", "pass2" ) e1 = request.param.Exam.objects.create(name="test exam", exam_mode=True) - e = request.param.Exam.objects.create(name="test exam 2", exam_mode=True) + e = request.param.Exam.objects.create(name="test exam basic user", exam_mode=True) e.author.add(basic_user) return request.param @@ -64,12 +64,31 @@ def test_index2(client, set_up_exams): == "2 exams found." ) +def test_overview(client, set_up_exams): + client.login(username="admin", password="adminpassword") + + e1 = set_up_exams.Exam.objects.get(name="test exam").pk + e2 = set_up_exams.Exam.objects.get(name="test exam basic user").pk + + response = client.get(reverse(f"{set_up_exams.app_name}:exam_overview", args=[e1])) + assert response.status_code == 200 + + response = client.get(reverse(f"{set_up_exams.app_name}:exam_overview", args=[e2])) + assert response.status_code == 200 + + client.login(username="basicuser", password="pass2") + response = client.get(reverse(f"{set_up_exams.app_name}:exam_overview", args=[e1])) + assert response.status_code == 403 + + response = client.get(reverse(f"{set_up_exams.app_name}:exam_overview", args=[e2])) + assert response.status_code == 200 + @pytest.mark.parametrize("exam_views", EXAM_VIEWS) def test_index(client, create_superuser, exam_views, django_user_model): basic_user = django_user_model.objects.create_user( - "user1", "user@user.net", "pass2" + "basicuser", "user@user.net", "pass2" ) e1 = exam_views.Exam.objects.create(name="test exam", exam_mode=True) diff --git a/rapids/urls.py b/rapids/urls.py index b62b1d6d..29608510 100755 --- a/rapids/urls.py +++ b/rapids/urls.py @@ -72,6 +72,10 @@ urlpatterns = [ name="exam_question_detail", ), path("exam//", views.GenericExamViews.exam_overview, name="exam_overview"), + path("exam//report/email", views.GenericExamViews.exam_report_email, name="exam_report_email"), + path("exam//report/email/status", views.GenericExamViews.exam_report_email_status, name="exam_report_email_status"), + path("exam//report/", views.GenericExamViews.exam_user_report, name="exam_user_report"), + path("exam//report//email", views.GenericExamViews.exam_user_report_email, name="exam_user_report_email"), path("exam//authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"), path( "exam//json_edit", diff --git a/sbas/migrations/0015_exam_exam_results_emailed.py b/sbas/migrations/0015_exam_exam_results_emailed.py new file mode 100644 index 00000000..34f1c069 --- /dev/null +++ b/sbas/migrations/0015_exam_exam_results_emailed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-26 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sbas', '0014_auto_20220521_2135'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='exam_results_emailed', + field=models.DateTimeField(default=None, null=True), + ), + ]