lost track of changes...

This commit is contained in:
Ross
2023-01-09 09:52:09 +00:00
parent 9fc457c8a5
commit af52658e44
28 changed files with 469 additions and 118 deletions
+1
View File
@@ -8,6 +8,7 @@
- Test coverage
- Privacy policy
- Cookie policy
- Cloning exams should include authors
# Future
+3 -1
View File
@@ -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()
+1 -1
View File
@@ -7,7 +7,7 @@
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'anatomy:exam_scores_cid' pk=exam.pk %}">Scores</a> /
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
{% endif %}
<a href="{% url 'anatomy:anatomy_create_exam' pk=exam.pk %}">Add New Question</a>
+11
View File
@@ -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)
@@ -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'),
),
]
@@ -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)),
],
),
]
+40 -5
View File
@@ -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"),
+52 -30
View File
@@ -1,49 +1,71 @@
<div class="card text-white bg-dark">
<h3>CID candidates</h3>
{% if cid_users %}
<p>{{cid_user_count}} CID candidates.</p>
{% if cid_users %}
<p>{{cid_user_count}} CID candidates.</p>
<ol>
{% for cid in cid_users %}
<ol>
{% for cid in cid_users %}
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}}
{% if cid.supervisor %} / Supervisor email: {{cid.supervisor.email}}{% endif %} <br />
Internal candidate: {{cid.internal_candidate}}
{% if cid.login_email_sent %} / Login email sent{% endif %}
{% if cid.results_email_sent %} / Results email sent{% endif %}
</li>
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}}
{% if cid.supervisor %} / Supervisor email: {{cid.supervisor.email}}{% endif %} <br />
Internal candidate: {{cid.internal_candidate}}
{% if cid.login_email_sent %} / Login email sent{% endif %}
{% if cid.results_email_sent %} / Results email sent{% endif %}
</li>
{% endfor %}
</ol>
{% else %}
<p>This exam has no CID candidates. Add some with the below link.</p>
{% endif %}
{% endfor %}
</ol>
{% else %}
<p>This exam has no CID candidates. Add some with the below link.</p>
{% endif %}
<a href="{% url exam.app_name|add:':exam_cids_edit' exam.pk %}">Edit Exam CIDs</a>
<a href="{% url exam.app_name|add:':exam_cids_edit' exam.pk %}">Edit Exam CIDs</a>
</div>
<br/>
<div class="card text-white bg-dark">
<h3>User candidates</h3>
{% if user_users %}
<p>{{user_user_count}} User candidates.</p>
{% if user_users %}
<p>{{user_user_count}} User candidates.</p>
<ol>
{% for user in user_users %}
<ol>
{% for user in user_users %}
<li>{{user.username}}
Email: {{user.email}}
</li>
<li>{{user.username}}
Email: {{user.email}}
</li>
{% endfor %}
</ol>
{% else %}
<p>This exam has no User candidates. Add some with the below link.</p>
{% endif %}
<a href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
</div>
<br/>
{% if user_exam_data %}
<div class="card text-white bg-dark">
<h3>Submitted candidates</h3>
<ul>
{% for user_data in user_exam_data %}
<li>
CID: {{user_data.cid_user}} /
User: {{user_data.user_user}} /
Start time: {{user_data.start_time}} /
End time: {{user_data.end_time}} /
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</ol>
{% else %}
<p>This exam has no User candidates. Add some with the below link.</p>
{% endif %}
<a href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
</div>
+1 -1
View File
@@ -18,7 +18,7 @@
{% if app_name in "rapids longs anatomy" %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}">Mark answers</a>{% endif %}
{% endif %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':exam_scores_cid' pk=exam.pk %}">Scores</a>{% endif %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}">Scores</a>{% endif %}
{% endif %}
</div>
{% endfor %}
+54 -14
View File
@@ -8,6 +8,22 @@
User answer scores are cached, if you update or change an answer you will need to <a href="{% url exam.app_name|add:':exam_scores_refresh' exam.pk %}">refresh the scores</a>.
{% endif %}
{% if missing_cids %}
<div class="alert alert-warning" role="alert">
The following CIDs results have not been submitted:
{% for cid in missing_cids %}
{{cid}},
{% endfor %}
</div>
{% endif %}
{% if missing_users %}
<div class="alert alert-warning" role="alert">
The following users results have not been submitted:
{% for user_id in missing_users %}
{{user_id}},
{% endfor %}
</div>
{% endif %}
{% if unmarked %}
<div class="alert alert-warning" role="alert">
The following questions need marking
@@ -15,13 +31,13 @@
<a href="{% url exam.app_name|add:':mark' exam.pk exam_index %}">{{ exam_index|add:1 }}</a>
{% endfor %}
</div>
{% endif %}
</div>
<div id="stats-block">
<h3>Stats</h3>
Candidates: {{cids|length}}<br />
Questions: <span id="question-number">{{question_number}}</span><br />
Max score: {{max_score}}<br />
Mean: {{mean}}, Median {{median}}, Mode {{mode}}
@@ -34,7 +50,7 @@
<th>Normalised Score</th>
</tr>
{% for cid in cids %}
<tr>
<tr class="candidate-row" data-answer-count={{user_answer_count|get_item:cid}}>
{% if cid|slice:":1" == "u" %}
<td>{{cids_user_id_map|get_item:cid}}</td>
{% else %}
@@ -90,22 +106,22 @@
<details>
<summary>Email results</summary>
User results emailed: {{exam.exam_results_emailed|default:"Never"}}<br/>
<button id="email-results-button"
title="Email results to users and their supervisor"
hx-get="{% url exam.app_name|add:':exam_report_email' exam_id=exam.pk %}"
hx-target="#user-details"
hx-confirm="This will email results, please make sure scores have been refreshed before continuing"
hx-prompt="Please enter an additional email to send to (user and supervisor will automatically be used if available) if required"
<button id="email-results-button"
title="Email results to users and their supervisor"
hx-get="{% url exam.app_name|add:':exam_report_email' exam_id=exam.pk %}"
hx-target="#user-details"
hx-confirm="This will email results, please make sure scores have been refreshed before continuing"
hx-prompt="Please enter an additional email to send to (user and supervisor will automatically be used if available) if required"
>Email user results</button>
<button id="email-unsent-results-button" title="Email results to users and their supervisor"
hx-get="{% url exam.app_name|add:':exam_report_email_unsent' exam_id=exam.pk %}"
hx-target="#user-details"
hx-confirm="This will email results, please make sure scores have been refreshed before continuing"
hx-prompt="Please enter an additional email to send to (user and supervisor will automatically be used if available) if required"
hx-get="{% url exam.app_name|add:':exam_report_email_unsent' exam_id=exam.pk %}"
hx-target="#user-details"
hx-confirm="This will email results, please make sure scores have been refreshed before continuing"
hx-prompt="Please enter an additional email to send to (user and supervisor will automatically be used if available) if required"
>Email unsent user results</button>
<button id="email-results-check-button" title="Check the status of emailed results"
hx-get="{% url exam.app_name|add:':exam_report_email_status' exam_id=exam.pk %}"
hx-target="#user-details">Check email status</button>
hx-get="{% url exam.app_name|add:':exam_report_email_status' exam_id=exam.pk %}"
hx-target="#user-details">Check email status</button>
<p>Note: currently only works with registered users</p>
<div id="user-details"></div>
</details>
@@ -113,3 +129,27 @@
</div>
{% endblock %}
{% block js %}
<script>
$(document).ready(() => {
let question_number = document.getElementById("question-number").text;
$(".candidate-row").each((index, el) => {
console.log(index, el)
if (el.dataset.answerCount != question_number) {
$(el).addClass("missing-answers")
}
});
});
</script>
<style>
.missing-answers td:first-child:before {
content: "*";
color: red;
}
</style>
{% endblock %}
+6
View File
@@ -27,5 +27,11 @@
<a href="{% url 'accounts_bulk_create' %}">Bulk create users</a>
<a href="{% url 'create_user' %}">Create single user</a>
<details>
<summary>
Bulk edit
</summary>
<button>Delete supervisors</button>
</details>
{% endblock %}
+15 -4
View File
@@ -181,13 +181,13 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
),
path(
"exam/<int:pk>/scores",
generic_exam_view.exam_scores_cid,
name="exam_scores_cid",
generic_exam_view.exam_scores_all,
name="exam_scores_all",
),
path(
"exam/<int:pk>/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/<int:pk>", generic_exam_view.exam_json, name="exam_json"),
path("exam/json/<int:pk>/<int:cid>/<str:passcode>", generic_exam_view.exam_json_cid, name="exam_json_cid"),
path(
"exam/json/<int:pk>/unbased",
generic_exam_view.exam_json_unbased,
name="exam_json_unbased",
),
path(
"exam/json/<int:pk>/<int:cid>/<str:passcode>/unbased",
generic_exam_view.exam_json_cid_unbased,
name="exam_json_cid_unbased",
),
path(
"exam/json/<int:pk>/<int:sk>",
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/<int:pk>/user_status",
generic_exam_view.exam_user_status,
name="exam_user_status",
),
]
return urlpatterns
+150 -35
View File
@@ -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 <br/>
<a href='{}'>Return</a>""",
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())
+15 -11
View File
@@ -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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"
+3
View File
@@ -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)
+1 -1
View File
@@ -6,6 +6,6 @@
Exams: {{exam.name}}->
<a href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'longs:exam_scores_cid' pk=exam.pk %}">Scores</a> /
<a href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> /
{% endblock %}
+1 -1
View File
@@ -88,7 +88,7 @@ urlpatterns = [
views.refresh_exam_question_json,
name="refresh_exam_question_json",
),
path("exam/<int:pk>/scores", views.exam_scores_cid, name="exam_scores_cid"),
path("exam/<int:pk>/scores", views.exam_scores_all, name="exam_scores_all"),
path(
"exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
views.exam_scores_cid_user,
+1 -1
View File
@@ -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()
+3 -1
View File
@@ -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})
+1 -1
View File
@@ -4,6 +4,6 @@
{{block.super}}
<br/>
Exams: {{exam.name}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'physics:exam_scores_cid' pk=exam.pk %}">Scores</a> /
<a href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a>
{% endblock %}
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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"})
+3
View File
@@ -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()
+1 -1
View File
@@ -7,7 +7,7 @@
<a href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'rapids:exam_scores_cid' pk=exam.pk %}">Scores</a> /
<a href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a> /
{% endif %}
<a href="{% url 'rapids:rapid_create_exam' pk=exam.pk %}">Add New Question</a>
+3 -1
View File
@@ -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})
+1 -1
View File
@@ -5,6 +5,6 @@
<br/>
Exams: {{exam.name}}->
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'sbas:exam_scores_cid' pk=exam.pk %}">Scores</a> /
<a href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a> /
{% endblock %}
+2 -2
View File
@@ -11,7 +11,7 @@
<a href="{% url app_name|add:':exam_overview' pk=exam.pk %}" class="flex-col-2 exam-name">{{exam.name}}</a>
{% if marking %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}" class="flex-col">Mark</a>{% endif %}
<a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}" class="flex-col">Candidates</a>
<a href="{% url app_name|add:':exam_scores_cid' pk=exam.pk %}" class="flex-col">Scores</a>
<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}" class="flex-col">Scores</a>
<input type="checkbox" id="active-{{exam.pk}}" class="exam-active-switch" data-posturl="{% url app_name|add:':exam_toggle_active' pk=exam.pk %}" {% if exam.active %}checked{% endif %}>
<label for="active-{{exam.pk}}" class="flex-col icon-container active-icon" title="Click to toggle active state">Exam Active</label>
<input type="checkbox" id="published-{{exam.pk}}" class="exam-publish-results-switch" data-posturl="{% url app_name|add:':exam_toggle_results_published' pk=exam.pk %}" {% if exam.publish_results %}checked{% endif %}>
@@ -33,7 +33,7 @@
<a href="{% url app_name|add:':exam_overview' pk=exam.pk %}" class="flex-col-2 exam-name">{{exam.name}}</a>
{% if marking %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}" class="flex-col">Mark</a>{% endif %}
<a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}" class="flex-col">Candidates</a>
<a href="{% url app_name|add:':exam_scores_cid' pk=exam.pk %}" class="flex-col">Scores</a>
<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}" class="flex-col">Scores</a>
<input type="checkbox" id="active-{{exam.pk}}" class="exam-active-switch" data-posturl="{% url app_name|add:':exam_toggle_active' pk=exam.pk %}" {% if exam.active %}checked{% endif %}>
<label for="active-{{exam.pk}}" class="flex-col icon-container active-icon" title="Click to toggle active state">Exam Active</label>
<input type="checkbox" id="{{exam.pk}}-pub" class="exam-publish-results-switch" data-posturl="{% url app_name|add:':exam_toggle_results_published' pk=exam.pk %}" {% if exam.publish_results %}checked{% endif %}>
+21
View File
@@ -0,0 +1,21 @@
{% if statuses %}
<div class="alert alert-warning" role="alert">
<ul>
{% for status in statuses %}
<li>{{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}}
</li>
{% endfor %}
</ul>
</div>
{% endif %}