diff --git a/TODO.md b/TODO.md index b6e953c8..aa6eb5ec 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,7 @@ - Test coverage - Privacy policy - Cookie policy +- Cloning exams should include authors # Future diff --git a/anatomy/models.py b/anatomy/models.py index 40063fb4..73898b16 100644 --- a/anatomy/models.py +++ b/anatomy/models.py @@ -16,7 +16,7 @@ from sortedm2m.fields import SortedManyToManyField import string -from generic.models import CidUser, CidUserGroup, Examination, ExamBase, QuestionNote, UserUserGroup +from generic.models import CidUser, CidUserGroup, ExamUserStatus, Examination, ExamBase, QuestionNote, UserUserGroup from collections import defaultdict from helpers.images import image_as_base64 @@ -415,6 +415,8 @@ class Exam(ExamBase): related_name="anatomy_user_user_groups" ) + exam_user_status = GenericRelation(ExamUserStatus) + def get_exam_json(self, based=True): questions = self.exam_questions.all() diff --git a/anatomy/templates/anatomy/exams.html b/anatomy/templates/anatomy/exams.html index 0b659555..27e654da 100644 --- a/anatomy/templates/anatomy/exams.html +++ b/anatomy/templates/anatomy/exams.html @@ -7,7 +7,7 @@ Overview / {% if exam.exam_mode %} Mark / - Scores / + Scores / Candidates / {% endif %} Add New Question diff --git a/anatomy/tests/test_anatomy_exams.py b/anatomy/tests/test_anatomy_exams.py index ef83ca02..acc7afff 100644 --- a/anatomy/tests/test_anatomy_exams.py +++ b/anatomy/tests/test_anatomy_exams.py @@ -116,6 +116,16 @@ def test_exams(db, client): cid_user_2001 = CidUser.objects.get(cid=2001) user1 = User.objects.get(username="user1") + # Test exam access urls + + # Check that we can't access without logging in + assert client.get(exam.get_json_url()).status_code == 404 + assert client.get(exam.get_json_url(cid=cid_user_2001.cid, passcode=cid_user_2001.passcode)).status_code == 404 + # Valid user but incorrect passcode + assert client.get(exam.get_json_url(cid=cid_user_1001.cid, passcode=cid_user_2001.passcode)).status_code == 404 + + + users_tested = 1 for cid_user in [cid_user_1001, cid_user_1000, user1]: if isinstance(cid_user, CidUser): @@ -168,6 +178,7 @@ def test_exams(db, client): assert exam_metadata["exam_active"] == True assert exam_metadata["exam_mode"] == True + # Check that we can access the exam json res = client.get(exam_metadata["url"]) assert res.status_code == 200 exam_json = json.loads(res.content) diff --git a/atlas/migrations/0047_alter_condition_parent_alter_condition_synonym_and_more.py b/atlas/migrations/0047_alter_condition_parent_alter_condition_synonym_and_more.py new file mode 100644 index 00000000..cc9103eb --- /dev/null +++ b/atlas/migrations/0047_alter_condition_parent_alter_condition_synonym_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.1.4 on 2022-12-19 13:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0046_alter_casedetail_sort_order'), + ] + + operations = [ + migrations.AlterField( + model_name='condition', + name='parent', + field=models.ManyToManyField(blank=True, to='atlas.condition'), + ), + migrations.AlterField( + model_name='condition', + name='synonym', + field=models.ManyToManyField(blank=True, to='atlas.condition'), + ), + migrations.AlterField( + model_name='finding', + name='synonym', + field=models.ManyToManyField(blank=True, to='atlas.finding'), + ), + migrations.AlterField( + model_name='structure', + name='synonym', + field=models.ManyToManyField(blank=True, to='atlas.structure'), + ), + ] diff --git a/generic/migrations/0048_alter_userusergroup_archive_alter_userusergroup_name_and_more.py b/generic/migrations/0048_alter_userusergroup_archive_alter_userusergroup_name_and_more.py new file mode 100644 index 00000000..404be327 --- /dev/null +++ b/generic/migrations/0048_alter_userusergroup_archive_alter_userusergroup_name_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.1.4 on 2022-12-19 13:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('generic', '0047_auto_20221121_1637'), + ] + + operations = [ + migrations.AlterField( + model_name='userusergroup', + name='archive', + field=models.BooleanField(default=False, help_text='Archived groups remain on the test system but are not displayed by default'), + ), + migrations.AlterField( + model_name='userusergroup', + name='name', + field=models.CharField(blank=True, help_text='Name of the User Group', max_length=50), + ), + migrations.CreateModel( + name='ExamUserStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datetime', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(max_length=255)), + ('extra', models.CharField(max_length=255)), + ('object_id', models.PositiveIntegerField()), + ('cid_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='generic.ciduser')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('user_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/generic/models.py b/generic/models.py index 695e7204..154d00e9 100644 --- a/generic/models.py +++ b/generic/models.py @@ -184,8 +184,11 @@ class ExamBase(models.Model): def get_exam_name(self): return self.name - def get_json_url(self): - return reverse("{}:exam_json".format(self.app_name), args=(self.pk,)) + def get_json_url(self, cid=None, passcode=None): + if cid is None: + return reverse("{}:exam_json".format(self.app_name), args=(self.pk,)) + else: + return reverse("{}:exam_json_cid".format(self.app_name), args=(self.pk,cid, passcode)) def get_question_index(self, question): return list(self.exam_questions.all()).index(question) @@ -200,17 +203,24 @@ class ExamBase(models.Model): authors = [i for i in self.author.all()] return authors - def check_cid_user(self, cid, passcode, request=None, user_id=None): + def check_cid_user(self, cid: int|None, passcode: str|None, request: HttpRequest|None=None, user_id: int|None=None, allow_authors: bool=True): if request is not None and request.user.is_superuser: return True - if not self.valid_cid_users.exists() and not self.valid_user_users.exists(): - return True + if user_id is not None: + if allow_authors: + if self.author.filter(pk=user_id).exists(): + return True + if not self.valid_cid_users.exists() and not self.valid_user_users.exists(): + return False + + # Start by checking if the logged in user can access if user_id is not None: if self.valid_user_users.filter(pk=user_id).exists(): return True + # Then test CID data if self.valid_cid_users.exists(): cid_user = self.valid_cid_users.filter(cid=cid).first() @@ -365,6 +375,24 @@ class ExamBase(models.Model): #self.save() return [True, ""] +class ExamUserStatus(models.Model): + datetime = models.DateTimeField(auto_now_add=True) + cid_user = models.ForeignKey("CidUser", blank=True, null=True, on_delete=models.SET_NULL) + user_user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL) + status = models.CharField(max_length=255) + extra = models.CharField(max_length=255) + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + exam = GenericForeignKey("content_type", "object_id") + + def __str__(self): + if self.cid_user: + user = self.cid_user.cid + else: + user = self.user_user.username + return f"{self.datetime}: {user} - {self.status} ({self.extra})" + class NoteType(models.Model): note_type = models.CharField(max_length=200) @@ -620,6 +648,13 @@ class CidUserExam(models.Model): # TODO switch to json field? results_emailed_status = models.CharField(max_length=255, blank=True) + def __str__(self) -> str: + if self.cid_user is None: + user = self.user_user.username + else: + user = self.cid_user.cid + return f"{user}: {self.start_time:%Y-%m-%d %H:%M} {self.end_time:%Y-%m-%d %H:%M}" + CID_GROUP_EXAMS = ( ("SBAs", "sba_cid_user_groups"), ("Physics", "physics_cid_user_groups"), diff --git a/generic/templates/generic/exam_cids.html b/generic/templates/generic/exam_cids.html index 0d12c4ee..aa4650ca 100644 --- a/generic/templates/generic/exam_cids.html +++ b/generic/templates/generic/exam_cids.html @@ -1,49 +1,71 @@

CID candidates

-{% if cid_users %} -

{{cid_user_count}} CID candidates.

+ {% if cid_users %} +

{{cid_user_count}} CID candidates.

-
    - {% for cid in cid_users %} +
      + {% for cid in cid_users %} -
    1. {{cid.cid}} [{{cid.passcode}}] {{cid.name}} / - Email: {{cid.email}} - {% if cid.supervisor %} / Supervisor email: {{cid.supervisor.email}}{% endif %}
      - Internal candidate: {{cid.internal_candidate}} - {% if cid.login_email_sent %} / Login email sent{% endif %} - {% if cid.results_email_sent %} / Results email sent{% endif %} -
    2. +
    3. {{cid.cid}} [{{cid.passcode}}] {{cid.name}} / + Email: {{cid.email}} + {% if cid.supervisor %} / Supervisor email: {{cid.supervisor.email}}{% endif %}
      + Internal candidate: {{cid.internal_candidate}} + {% if cid.login_email_sent %} / Login email sent{% endif %} + {% if cid.results_email_sent %} / Results email sent{% endif %} +
    4. - {% endfor %} -
    -{% else %} -

    This exam has no CID candidates. Add some with the below link.

    -{% endif %} + {% endfor %} +
+ {% else %} +

This exam has no CID candidates. Add some with the below link.

+ {% endif %} -Edit Exam CIDs + Edit Exam CIDs

User candidates

-{% if user_users %} -

{{user_user_count}} User candidates.

+ {% if user_users %} +

{{user_user_count}} User candidates.

-
    - {% for user in user_users %} +
      + {% for user in user_users %} -
    1. {{user.username}} - Email: {{user.email}} -
    2. +
    3. {{user.username}} + Email: {{user.email}} +
    4. + + {% endfor %} +
    + {% else %} +

    This exam has no User candidates. Add some with the below link.

    + {% endif %} + Edit Exam Users + +
+ +
+ +{% if user_exam_data %} +
+

Submitted candidates

+ +
- {% endfor %} - -{% else %} -

This exam has no User candidates. Add some with the below link.

{% endif %} -Edit Exam Users - \ No newline at end of file + diff --git a/generic/templates/generic/exam_index.html b/generic/templates/generic/exam_index.html index 09246716..4a78b086 100644 --- a/generic/templates/generic/exam_index.html +++ b/generic/templates/generic/exam_index.html @@ -18,7 +18,7 @@ {% if app_name in "rapids longs anatomy" %} {% if request.user.is_staff %}Mark answers{% endif %} {% endif %} - {% if request.user.is_staff %}Scores{% endif %} + {% if request.user.is_staff %}Scores{% endif %} {% endif %} {% endfor %} diff --git a/generic/templates/generic/exam_scores_base.html b/generic/templates/generic/exam_scores_base.html index 12a128f0..b6a57ecd 100644 --- a/generic/templates/generic/exam_scores_base.html +++ b/generic/templates/generic/exam_scores_base.html @@ -8,6 +8,22 @@ User answer scores are cached, if you update or change an answer you will need to refresh the scores. {% endif %} + {% if missing_cids %} + + {% endif %} + {% if missing_users %} + + {% endif %} {% if unmarked %} - {% endif %}

Stats

Candidates: {{cids|length}}
+ Questions: {{question_number}}
Max score: {{max_score}}
Mean: {{mean}}, Median {{median}}, Mode {{mode}} @@ -34,7 +50,7 @@ Normalised Score {% for cid in cids %} - + {% if cid|slice:":1" == "u" %} {{cids_user_id_map|get_item:cid}} {% else %} @@ -90,22 +106,22 @@
Email results User results emailed: {{exam.exam_results_emailed|default:"Never"}}
- + hx-get="{% url exam.app_name|add:':exam_report_email_status' exam_id=exam.pk %}" + hx-target="#user-details">Check email status

Note: currently only works with registered users

@@ -113,3 +129,27 @@
{% endblock %} + +{% block js %} + + + +{% endblock %} \ No newline at end of file diff --git a/generic/templates/generic/user_view.html b/generic/templates/generic/user_view.html index cd64bc49..88ad5967 100644 --- a/generic/templates/generic/user_view.html +++ b/generic/templates/generic/user_view.html @@ -27,5 +27,11 @@ Bulk create users Create single user +
+ + Bulk edit + + +
{% endblock %} diff --git a/generic/urls.py b/generic/urls.py index d126cdfc..9c8ce04f 100755 --- a/generic/urls.py +++ b/generic/urls.py @@ -181,13 +181,13 @@ def generic_exam_urls(generic_exam_view: GenericExamViews): ), path( "exam//scores", - generic_exam_view.exam_scores_cid, - name="exam_scores_cid", + generic_exam_view.exam_scores_all, + name="exam_scores_all", ), path( "exam//scores/refresh", generic_exam_view.exam_scores_refresh, - # cache_page(60 * 1)(exam_scores_cid), + # cache_page(60 * 1)(exam_scores_all), name="exam_scores_refresh", ), path( @@ -217,7 +217,7 @@ def generic_exam_urls(generic_exam_view: GenericExamViews): ), path( "exam/submit", - generic_exam_view.postExamAnswers, + generic_exam_view.post_exam_answers, name="exam_answers_submit", ), path("exam/", generic_exam_view.exam_list, name="exam_list"), @@ -235,11 +235,17 @@ def generic_exam_urls(generic_exam_view: GenericExamViews): ), path("exam/json/", generic_exam_view.active_exams, name="active_exams"), path("exam/json/", generic_exam_view.exam_json, name="exam_json"), + path("exam/json///", generic_exam_view.exam_json_cid, name="exam_json_cid"), path( "exam/json//unbased", generic_exam_view.exam_json_unbased, name="exam_json_unbased", ), + path( + "exam/json////unbased", + generic_exam_view.exam_json_cid_unbased, + name="exam_json_cid_unbased", + ), path( "exam/json//", generic_exam_view.exam_question_json, @@ -255,5 +261,10 @@ def generic_exam_urls(generic_exam_view: GenericExamViews): generic_exam_view.exam_json_recreate, name="exam_json_recreate", ), + path( + "exam//user_status", + generic_exam_view.exam_user_status, + name="exam_user_status", + ), ] return urlpatterns diff --git a/generic/views.py b/generic/views.py index 21815789..475a44db 100644 --- a/generic/views.py +++ b/generic/views.py @@ -17,7 +17,7 @@ from django.utils.html import format_html from django.views.decorators.csrf import csrf_exempt from django.core.exceptions import PermissionDenied, FieldError -from django.http import Http404, JsonResponse +from django.http import Http404, HttpRequest, JsonResponse from django.http import HttpResponseRedirect, HttpResponse from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin @@ -58,7 +58,7 @@ from .forms import ( UserUserGroupForm, ) -from .models import CidUser, CidUserGroup, ExamBase, Examination, QuestionNote, Supervisor, UserGrades, UserProfile, UserUserGroup, get_next_cid +from .models import CidUser, CidUserGroup, ExamBase, ExamUserStatus, Examination, QuestionNote, Supervisor, UserGrades, UserProfile, UserUserGroup, get_next_cid from rapids.models import Rapid as RapidQuestion from rapids.models import Exam as RapidExam @@ -87,6 +87,9 @@ from django.db.models import Prefetch class AuthorRequiredMixin(object): def get_object(self, *args, **kwargs): obj = super().get_object(*args, **kwargs) + if self.request.user.is_superuser: + return obj + if self.request.user not in obj.author.all(): raise PermissionDenied() # or Http404 return obj @@ -861,12 +864,24 @@ class ExamViews(View, LoginRequiredMixin): "simple_content": format_html( """Answer scores updated
Return""", - reverse(f"{self.app_name}:exam_scores_cid", kwargs={"pk": exam.pk}), + reverse(f"{self.app_name}:exam_scores_all", kwargs={"pk": exam.pk}), ) }, ) def exam_cids(self, request, exam_id): + """View that displays an overview of users that have been added to the exam. + + Args: + request (_type_): _description_ + exam_id (_type_): _description_ + + Raises: + PermissionDenied: _description_ + + Returns: + _type_: _description_ + """ exam = get_object_or_404(self.Exam, pk=exam_id) # if not request.user.groups.filter(name="cid_user_manager").exists(): @@ -874,8 +889,7 @@ class ExamViews(View, LoginRequiredMixin): if request.user not in exam.author.all(): if not request.user.is_superuser: - if not self.check_user_access(request.user, exam_id): - raise PermissionDenied + raise PermissionDenied cid_users = exam.valid_cid_users.all() cid_user_count = cid_users.count() @@ -883,10 +897,13 @@ class ExamViews(View, LoginRequiredMixin): user_users = exam.valid_user_users.all() user_user_count = user_users.count() + user_exam_data = exam.cid_users.all() + context = { "exam": exam, "cid_users": cid_users, "cid_user_count": cid_user_count, + "user_exam_data": user_exam_data, "user_users": user_users, "user_user_count": user_user_count, "app_name": self.app_name, @@ -903,8 +920,7 @@ class ExamViews(View, LoginRequiredMixin): if not request.user.groups.filter(name="cid_user_manager").exists(): # raise PermissionDenied if request.user not in exam.author.all(): - if not self.check_user_access(request.user, exam_id): - raise PermissionDenied + raise PermissionDenied current_user_users = exam.valid_user_users.all() @@ -937,8 +953,7 @@ class ExamViews(View, LoginRequiredMixin): if not request.user.groups.filter(name="cid_user_manager").exists(): # raise PermissionDenied if request.user not in exam.author.all(): - if not self.check_user_access(request.user, exam_id): - raise PermissionDenied + raise PermissionDenied current_cid_users = exam.valid_cid_users.all() @@ -980,6 +995,29 @@ class ExamViews(View, LoginRequiredMixin): return notes + def exam_user_status(self, request, pk): + exam = get_object_or_404(self.Exam, pk=pk) + + # Restrict just to exam authors + if request.user not in exam.author.all(): + raise PermissionDenied + + statuses = exam.exam_user_status.all() + + return render(request, "exam_user_status.html", {"exam": exam, "statuses": statuses}) + + #if not self.check_user_access(request.user, pk): + # raise PermissionDenied + + #q, content_type = get_question_and_content_type(self.question_type) + + ## Get active notes for type + #statuses = ExamUserStatus.objects.filter( + # content_type=content_type, object_id__in=pk + #) + + #return statuses + @method_decorator(login_required) def exam_json_edit(self, request, pk): if request.method == "POST": @@ -1252,7 +1290,7 @@ class ExamViews(View, LoginRequiredMixin): }, ) - def active_exams(self, request, json=True, based=True, cid=None, passcode=None): + def active_exams(self, request: HttpRequest, json: bool=True, based: bool=True, cid: int=None, passcode: str=None): exams = self.Exam.objects.filter(archive=False) active_exams = {"exams": []} @@ -1264,11 +1302,12 @@ class ExamViews(View, LoginRequiredMixin): return active_exams["exams"] return JsonResponse({"status": "invalid"}, status=401) + exam: ExamBase for exam in exams: if exam.active or self.check_user_access(request.user, exam.pk): - print(exam.name, cid, passcode) + print(f"{exam.app_name=}, {exam.name=}, {cid=}, {passcode=}, {request.user=}") if exam.exam_mode and not exam.check_cid_user( - cid, passcode, user_id=request.user.pk + cid, passcode, request=request, user_id=request.user.pk ): print(exam.name, "fail") continue @@ -1278,7 +1317,7 @@ class ExamViews(View, LoginRequiredMixin): else: creation_time = "None" - url = request.build_absolute_uri(exam.get_json_url()) + url = request.build_absolute_uri(exam.get_json_url(cid=cid, passcode=passcode)) # hacky if not based: url = url + "/unbased" @@ -1310,7 +1349,7 @@ class ExamViews(View, LoginRequiredMixin): return JsonResponse(active_exams) @method_decorator(csrf_exempt) - def postExamAnswers(self, request): + def post_exam_answers(self, request): if request.method == "POST": n = 0 @@ -1486,32 +1525,42 @@ class ExamViews(View, LoginRequiredMixin): return JsonResponse({"success": True, "question_count": n}) return JsonResponse({"success": False, "error": "Invalid data"}) - def exam_json_unbased(self, request, pk): - """ - No (file based) caching is enabled for unbased exams - """ - exam = get_object_or_404(self.Exam, pk=pk) + def exam_json_cid(self, request, pk, cid, passcode): + return self.exam_json(request, pk, cid, passcode) + + def exam_json_cid_unbased(self, request, pk, cid, passcode): + return self.exam_json_unbased(request, pk, cid, passcode) + + def exam_json(self, request, pk, cid=None, passcode=None): + + exam: ExamBase = get_object_or_404(self.Exam, pk=pk) + + # Check access (for logged in users when inactive) if not exam.active and not self.check_user_access(request.user, pk): raise Http404("No available exam") - time = datetime.now() + # TODO: check access for logged in users - exam_json = exam.get_exam_json(based=False) - exam_json["generated"] = time.isoformat() - exam_json["exam_json_id"] = exam.exam_json_id - # exam_json["exam_active"] = False - return JsonResponse(exam_json) - - def exam_json(self, request, pk): - - exam = get_object_or_404(self.Exam, pk=pk) - - if not exam.active and not self.check_user_access(request.user, pk): + # Check access for users + if request.user.is_anonymous: + user_id = None + else: + user_id = request.user.pk + if not exam.check_cid_user(cid, passcode, request, user_id): raise Http404("No available exam") # exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk)) + # Log exam access + user = request.user + if cid is not None: + user = None + cid_user = CidUser.objects.get(cid=cid) + else: + cid_user = None + exam.exam_user_status.create(cid_user=cid_user, user_user=user, status="downloaded") + path = "{0}{1}/exam/{2}.json".format(settings.MEDIA_ROOT, self.app_name, pk) url = "{0}{1}/exam/{2}.json".format(settings.MEDIA_URL, self.app_name, pk) @@ -1552,6 +1601,32 @@ class ExamViews(View, LoginRequiredMixin): return JsonResponse(exam_json) + def exam_json_unbased(self, request, pk, cid=None, passcode=None): + """ + No (file based) caching is enabled for unbased exams + """ + exam = get_object_or_404(self.Exam, pk=pk) + + if not exam.active and not self.check_user_access(request.user, pk): + raise Http404("No available exam") + + time = datetime.now() + + exam_json = exam.get_exam_json(based=False) + exam_json["generated"] = time.isoformat() + exam_json["exam_json_id"] = exam.exam_json_id + # exam_json["exam_active"] = False + # Log exam access + user = request.user + if cid is not None: + user = None + cid_user = CidUser.objects.get(cid=cid) + else: + cid_user = None + exam.exam_user_status.create(cid_user=cid, user_user=user, status="downloaded", extra="unbased") + + return JsonResponse(exam_json) + def exam_question_json(self, request, pk, sk): question = get_object_or_404(self.Question, pk=sk) exam = get_object_or_404(self.Exam, pk=pk) @@ -1687,12 +1762,29 @@ class ExamViews(View, LoginRequiredMixin): template_context, ) - def exam_scores_cid(self, request, pk): + def exam_scores_all(self, request, pk): + """The exam scores pages. Displays all user scores in a tabular format. + + Args: + request (_type_): _description_ + pk (_type_): _description_ + + Raises: + Http404: _description_ + PermissionDenied: _description_ + + Returns: + _type_: _description_ + """ exam = get_object_or_404(self.Exam, pk=pk) if not exam.exam_mode: raise Http404("Packet not in exam mode") + # Restrict access to the scores page just to exam authors + if request.user not in exam.author.all(): + raise PermissionDenied + # user_answers_and_marks = defaultdict(list) user_answers_marks = defaultdict(list) user_answers = defaultdict(list) @@ -1704,6 +1796,10 @@ class ExamViews(View, LoginRequiredMixin): cached_scores = True + + valid_cid_users = set(exam.valid_cid_users.all().values_list("cid", flat=True)) + valid_user_users = set(exam.valid_user_users.all().values_list("pk", flat=True)) + if self.app_name in ("rapids"): user_answers_callstates = defaultdict(list) user_answers_callstates_counted = {} @@ -1719,8 +1815,13 @@ class ExamViews(View, LoginRequiredMixin): question__in=questions, exam__id=pk ) + question_number = questions.count() + cids = set() + plain_cids = set() + user_ids = set() + cids_user_id_map = {} # Loop through all candidates @@ -1731,6 +1832,7 @@ class ExamViews(View, LoginRequiredMixin): cids_user_id_map[cid] = cid # cid_passcodes[cid] = cid_user_answer.passcode cids.add(cid) + plain_cids.add(cid_user_answer.cid) else: cid = f"u/{cid_user_answer.user.pk}" # cids_user_id_map[cid] = cid_user_answer.user.username @@ -1739,6 +1841,7 @@ class ExamViews(View, LoginRequiredMixin): name = cid_user_answer.user.username cids_user_id_map[cid] = name cids.add(cid) + user_ids.add(cid_user_answer.user.pk) s = cid_user_answer # user_names[cid] = cid @@ -1773,6 +1876,7 @@ class ExamViews(View, LoginRequiredMixin): user_scores = {} user_scores_normalised = {} + user_answer_count = {} for user in user_answers_marks: if self.app_name in ("rapids", "anatomy", "sbas"): @@ -1791,15 +1895,17 @@ class ExamViews(View, LoginRequiredMixin): else: user_scores_normalised[user] = user_scores[user] + user_answer_count[user] = len(user_answers_marks[user]) + # 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 = len(questions) * 2 + max_score = question_number * 2 elif self.app_name == "physics": - max_score = len(questions) * 5 + max_score = question_number * 5 else: - max_score = len(questions) + max_score = question_number if len(user_scores_list) < 1: mean = 0 @@ -1856,10 +1962,13 @@ class ExamViews(View, LoginRequiredMixin): template_variables = { "cids": sorted(cids), + "missing_cids": valid_cid_users - plain_cids, + "missing_users": valid_user_users - user_ids, # "cid_passcodes": cid_passcodes, "exam": exam, "unmarked": unmarked, "questions": questions, + "question_number": question_number, "by_question": by_question, # "user_answers": dict(user_answers), # "user_answers_marks": dict(user_answers_marks), @@ -1868,6 +1977,7 @@ class ExamViews(View, LoginRequiredMixin): # "user_scores_list": user_scores_list, # "user_names": user_names, # "user_answers_and_marks": user_answers_and_marks, + "user_answer_count": user_answer_count, "cids_user_id_map": cids_user_id_map, "max_score": max_score, "mean": mean, @@ -2001,15 +2111,20 @@ class ExamCloneMixin: old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"]) initial_data = model_to_dict(old_object, exclude=["id"]) + # We manually transfer the forign keys / m2m relationships questions = old_object.exam_questions.all().values_list("id", flat=True) + authors = old_object.author.all().values_list("id", flat=True) self.exam_questions = list(questions) + self.author = list(authors) return initial_data def form_valid(self, form): object = form.save() + # Reapply these otherwise they get lost? object.exam_questions.set(self.exam_questions) + object.author.set(self.author) object.save() return HttpResponseRedirect(object.get_absolute_url()) diff --git a/helpers/images.py b/helpers/images.py index c753064e..89af1a00 100644 --- a/helpers/images.py +++ b/helpers/images.py @@ -24,17 +24,21 @@ def image_as_base64(image_file): # if not os.path.isfile(image_file): # return None - encoded_string = "" - # with open(image_file, 'rb') as img_f: - # encoded_string = base64.b64encode(img_f.read()) - encoded_string = base64.b64encode(image_file.file.read()) - mimetype, enc = mimetypes.guess_type(image_file.path) - # Treat unknown files as dicom - if None == mimetype: - return "data:application/dicom;base64,{}".format(encoded_string.decode("utf-8")) - if "dicom" in mimetype or "octet-stream" in mimetype: - return "data:{};base64,{}".format(mimetype, encoded_string.decode("utf-8")) - return "data:image/{};base64,{}".format(mimetype, encoded_string.decode("utf-8")) + try: + encoded_string = "" + # with open(image_file, 'rb') as img_f: + # encoded_string = base64.b64encode(img_f.read()) + encoded_string = base64.b64encode(image_file.file.read()) + mimetype, enc = mimetypes.guess_type(image_file.path) + # Treat unknown files as dicom + if None == mimetype: + return "data:application/dicom;base64,{}".format(encoded_string.decode("utf-8")) + if "dicom" in mimetype or "octet-stream" in mimetype: + return "data:{};base64,{}".format(mimetype, encoded_string.decode("utf-8")) + return "data:image/{};base64,{}".format(mimetype, encoded_string.decode("utf-8")) + except FileNotFoundError: + return "data:image/png;base64,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" + diff --git a/longs/models.py b/longs/models.py index 6bb96530..3f671607 100644 --- a/longs/models.py +++ b/longs/models.py @@ -31,6 +31,7 @@ from anatomy.models import Modality from generic.models import ( CidUser, CidUserGroup, + ExamUserStatus, Examination, Condition, Sign, @@ -627,6 +628,8 @@ class Exam(ExamBase): related_name="longs_user_user_groups", ) + exam_user_status = GenericRelation(ExamUserStatus) + def get_exam_question_json(self, question_id): q = get_object_or_404(Long, pk=question_id) diff --git a/longs/templates/longs/exams.html b/longs/templates/longs/exams.html index de490be5..6260201b 100644 --- a/longs/templates/longs/exams.html +++ b/longs/templates/longs/exams.html @@ -6,6 +6,6 @@ Exams: {{exam.name}}-> Overview / Mark / - Scores / + Scores / Candidates / {% endblock %} diff --git a/longs/urls.py b/longs/urls.py index 0c2e90dd..4d04402b 100755 --- a/longs/urls.py +++ b/longs/urls.py @@ -88,7 +88,7 @@ urlpatterns = [ views.refresh_exam_question_json, name="refresh_exam_question_json", ), - path("exam//scores", views.exam_scores_cid, name="exam_scores_cid"), + path("exam//scores", views.exam_scores_all, name="exam_scores_all"), path( "exam//scores///", views.exam_scores_cid_user, diff --git a/longs/views.py b/longs/views.py index 845555c7..0b709872 100755 --- a/longs/views.py +++ b/longs/views.py @@ -862,7 +862,7 @@ def mark_question_overview(request, exam_id, sk): @login_required @user_is_long_marker -def exam_scores_cid(request, pk): +def exam_scores_all(request, pk): exam = get_object_or_404(Exam, pk=pk) questions = exam.exam_questions.all() diff --git a/physics/models.py b/physics/models.py index 23ffc7f5..187aab17 100644 --- a/physics/models.py +++ b/physics/models.py @@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _ from sortedm2m.fields import SortedManyToManyField -from generic.models import CidUser, CidUserGroup, ExamBase, QuestionNote, UserUserGroup +from generic.models import CidUser, CidUserGroup, ExamBase, ExamUserStatus, QuestionNote, UserUserGroup import reversion @@ -175,6 +175,8 @@ class Exam(ExamBase): related_name="physics_user_user_groups" ) + exam_user_status = GenericRelation(ExamUserStatus) + def get_take_url(self): return reverse("physics:exam_start", kwargs={"pk": self.pk}) diff --git a/physics/templates/physics/exams.html b/physics/templates/physics/exams.html index a5345c2b..5ce6582c 100644 --- a/physics/templates/physics/exams.html +++ b/physics/templates/physics/exams.html @@ -4,6 +4,6 @@ {{block.super}}
Exams: {{exam.name}}-> Overview / -Scores / +Scores / Candidates {% endblock %} \ No newline at end of file diff --git a/rad/test_helpers.py b/rad/test_helpers.py index 2523bf8f..dce6a7a7 100644 --- a/rad/test_helpers.py +++ b/rad/test_helpers.py @@ -15,7 +15,7 @@ def create_cid_user_and_groups(db): cid_user_1000 = CidUser.objects.create(cid=1000, passcode="ABCD", group=group1) cid_user_1001 = CidUser.objects.create(cid=1001, passcode="EFGH", group=group1) - cid_user_2001 = CidUser.objects.create(cid=2001, passcode="EFGH", group=group2) + cid_user_2001 = CidUser.objects.create(cid=2001, passcode="IJKL", group=group2) assert cid_user_1000.cid == 1000 assert cid_user_1001.cid == 1001 diff --git a/rad/views.py b/rad/views.py index ef611254..1ace4bd1 100644 --- a/rad/views.py +++ b/rad/views.py @@ -287,16 +287,16 @@ def exam_submit(request): try: if exam_type == "anatomy": - return AnatomyExamViews.postExamAnswers(request) + return AnatomyExamViews.post_exam_answers(request) elif exam_type == "rapid": - return RapidsExamsViews.postExamAnswers(request) + return RapidsExamsViews.post_exam_answers(request) elif exam_type == "long": - return LongsExamViews.postExamAnswers(request) + return LongsExamViews.post_exam_answers(request) return JsonResponse({"success": False, "error": "Invalid exam type"}) except ObjectDoesNotExist: return JsonResponse({"success": False, "error": "Invalid data"}) - # postExamAnswers + # post_exam_answers return JsonResponse({"success": False, "error": "Invalid data"}) @@ -405,7 +405,7 @@ def answer_suggestion_submit(request): a.save() return JsonResponse({"success": True, "error": "Answer submited"}) - # postExamAnswers + # post_exam_answers return JsonResponse({"success": False, "error": "Invalid data"}) diff --git a/rapids/models.py b/rapids/models.py index 242e024e..5e74dfcf 100644 --- a/rapids/models.py +++ b/rapids/models.py @@ -30,6 +30,7 @@ from django.contrib.contenttypes.fields import GenericRelation from generic.models import ( CidUser, CidUserGroup, + ExamUserStatus, Site, Condition, Sign, @@ -643,6 +644,8 @@ class Exam(ExamBase): related_name="rapid_user_user_groups" ) + exam_user_status = GenericRelation(ExamUserStatus) + def get_normal_abnormal_breakdown(self): # Inefficient but more extendible questions = self.exam_questions.all() diff --git a/rapids/templates/rapids/exams.html b/rapids/templates/rapids/exams.html index 1d257c34..301db95a 100644 --- a/rapids/templates/rapids/exams.html +++ b/rapids/templates/rapids/exams.html @@ -7,7 +7,7 @@ Overview / {% if exam.exam_mode %} Mark / - Scores / + Scores / Candidates / {% endif %} Add New Question diff --git a/sbas/models.py b/sbas/models.py index d961d55b..6514694c 100644 --- a/sbas/models.py +++ b/sbas/models.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ from sortedm2m.fields import SortedManyToManyField -from generic.models import CidUser, CidUserGroup, ExamBase, QuestionNote, UserUserGroup +from generic.models import CidUser, CidUserGroup, ExamBase, ExamUserStatus, QuestionNote, UserUserGroup import reversion @@ -198,6 +198,8 @@ class Exam(ExamBase): related_name="sba_user_user_groups" ) + exam_user_status = GenericRelation(ExamUserStatus) + def get_take_url(self): return reverse("sbas:exam_start", kwargs={"pk": self.pk}) diff --git a/sbas/templates/sbas/exams.html b/sbas/templates/sbas/exams.html index bda4e868..b6675e02 100644 --- a/sbas/templates/sbas/exams.html +++ b/sbas/templates/sbas/exams.html @@ -5,6 +5,6 @@
Exams: {{exam.name}}-> Overview / - Scores / + Scores / Candidates / {% endblock %} diff --git a/templates/exam_list.html b/templates/exam_list.html index 1686b135..b82c6860 100644 --- a/templates/exam_list.html +++ b/templates/exam_list.html @@ -11,7 +11,7 @@ {{exam.name}} {% if marking %}Mark{% endif %} Candidates - Scores + Scores @@ -33,7 +33,7 @@ {{exam.name}} {% if marking %}Mark{% endif %} Candidates - Scores + Scores diff --git a/templates/exam_user_status.html b/templates/exam_user_status.html new file mode 100644 index 00000000..dc0f7b3b --- /dev/null +++ b/templates/exam_user_status.html @@ -0,0 +1,21 @@ +{% if statuses %} + +{% endif %}