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 %}
- {{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 %}
-
+ {{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 %}
+
- {% 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 %}
- {{user.username}}
- Email: {{user.email}}
-
+ {{user.username}}
+ Email: {{user.email}}
+
+
+ {% endfor %}
+
+ {% else %}
+ This exam has no User candidates. Add some with the below link.
+ {% endif %}
+ Edit Exam Users
+
+
+
+
+
+{% if user_exam_data %}
+
+
Submitted candidates
+
+ {% for user_data in user_exam_data %}
+
+ CID: {{user_data.cid_user}} /
+ User: {{user_data.user_user}} /
+ Start time: {{user_data.start_time}} /
+ End time: {{user_data.end_time}} /
+
+
+ {% endfor %}
+
+
- {% 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 %}
+
+ The following CIDs results have not been submitted:
+ {% for cid in missing_cids %}
+ {{cid}},
+ {% endfor %}
+
+ {% endif %}
+ {% if missing_users %}
+
+ The following users results have not been submitted:
+ {% for user_id in missing_users %}
+ {{user_id}},
+ {% endfor %}
+
+ {% 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"}}
- Email user results
Email unsent user results
Check email status
+ 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
+
+ Delete supervisors
+
{% 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
Exam Active
@@ -33,7 +33,7 @@
{{exam.name}}
{% if marking %}Mark {% endif %}
Candidates
- Scores
+ Scores
Exam Active
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 %}
+
+
+ {% for status in statuses %}
+ {{status.datetime|date:"SHORT_DATETIME_FORMAT"}}:
+
+ {% if status.user_user %}
+ {{status.user_user}}
+ {% endif %}
+ {% if status.cid_user %}
+ {{status.cid_user}}
+ {% endif %}
+ {{status.status}}
+ {{status.extra}}
+
+
+
+ {% endfor %}
+
+
+{% endif %}