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 - Test coverage
- Privacy policy - Privacy policy
- Cookie policy - Cookie policy
- Cloning exams should include authors
# Future # Future
+3 -1
View File
@@ -16,7 +16,7 @@ from sortedm2m.fields import SortedManyToManyField
import string 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 collections import defaultdict
from helpers.images import image_as_base64 from helpers.images import image_as_base64
@@ -415,6 +415,8 @@ class Exam(ExamBase):
related_name="anatomy_user_user_groups" related_name="anatomy_user_user_groups"
) )
exam_user_status = GenericRelation(ExamUserStatus)
def get_exam_json(self, based=True): def get_exam_json(self, based=True):
questions = self.exam_questions.all() questions = self.exam_questions.all()
+1 -1
View File
@@ -7,7 +7,7 @@
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> / <a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %} {% if exam.exam_mode %}
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> / <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> / <a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
{% endif %} {% endif %}
<a href="{% url 'anatomy:anatomy_create_exam' pk=exam.pk %}">Add New Question</a> <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) cid_user_2001 = CidUser.objects.get(cid=2001)
user1 = User.objects.get(username="user1") 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 users_tested = 1
for cid_user in [cid_user_1001, cid_user_1000, user1]: for cid_user in [cid_user_1001, cid_user_1000, user1]:
if isinstance(cid_user, CidUser): if isinstance(cid_user, CidUser):
@@ -168,6 +178,7 @@ def test_exams(db, client):
assert exam_metadata["exam_active"] == True assert exam_metadata["exam_active"] == True
assert exam_metadata["exam_mode"] == True assert exam_metadata["exam_mode"] == True
# Check that we can access the exam json
res = client.get(exam_metadata["url"]) res = client.get(exam_metadata["url"])
assert res.status_code == 200 assert res.status_code == 200
exam_json = json.loads(res.content) 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)),
],
),
]
+38 -3
View File
@@ -184,8 +184,11 @@ class ExamBase(models.Model):
def get_exam_name(self): def get_exam_name(self):
return self.name return self.name
def get_json_url(self): def get_json_url(self, cid=None, passcode=None):
if cid is None:
return reverse("{}:exam_json".format(self.app_name), args=(self.pk,)) 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): def get_question_index(self, question):
return list(self.exam_questions.all()).index(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()] authors = [i for i in self.author.all()]
return authors 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: if request is not None and request.user.is_superuser:
return True return True
if not self.valid_cid_users.exists() and not self.valid_user_users.exists(): if user_id is not None:
if allow_authors:
if self.author.filter(pk=user_id).exists():
return True 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 user_id is not None:
if self.valid_user_users.filter(pk=user_id).exists(): if self.valid_user_users.filter(pk=user_id).exists():
return True return True
# Then test CID data
if self.valid_cid_users.exists(): if self.valid_cid_users.exists():
cid_user = self.valid_cid_users.filter(cid=cid).first() cid_user = self.valid_cid_users.filter(cid=cid).first()
@@ -365,6 +375,24 @@ class ExamBase(models.Model):
#self.save() #self.save()
return [True, ""] 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): class NoteType(models.Model):
note_type = models.CharField(max_length=200) note_type = models.CharField(max_length=200)
@@ -620,6 +648,13 @@ class CidUserExam(models.Model):
# TODO switch to json field? # TODO switch to json field?
results_emailed_status = models.CharField(max_length=255, blank=True) 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 = ( CID_GROUP_EXAMS = (
("SBAs", "sba_cid_user_groups"), ("SBAs", "sba_cid_user_groups"),
("Physics", "physics_cid_user_groups"), ("Physics", "physics_cid_user_groups"),
+31 -9
View File
@@ -1,7 +1,7 @@
<div class="card text-white bg-dark"> <div class="card text-white bg-dark">
<h3>CID candidates</h3> <h3>CID candidates</h3>
{% if cid_users %} {% if cid_users %}
<p>{{cid_user_count}} CID candidates.</p> <p>{{cid_user_count}} CID candidates.</p>
<ol> <ol>
@@ -17,18 +17,18 @@
{% endfor %} {% endfor %}
</ol> </ol>
{% else %} {% else %}
<p>This exam has no CID candidates. Add some with the below link.</p> <p>This exam has no CID candidates. Add some with the below link.</p>
{% endif %} {% 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> </div>
<br/> <br/>
<div class="card text-white bg-dark"> <div class="card text-white bg-dark">
<h3>User candidates</h3> <h3>User candidates</h3>
{% if user_users %} {% if user_users %}
<p>{{user_user_count}} User candidates.</p> <p>{{user_user_count}} User candidates.</p>
<ol> <ol>
@@ -40,10 +40,32 @@
{% endfor %} {% endfor %}
</ol> </ol>
{% else %} {% else %}
<p>This exam has no User candidates. Add some with the below link.</p> <p>This exam has no User candidates. Add some with the below link.</p>
{% endif %} {% endif %}
<a href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a> <a href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
</div> </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>
{% endif %}
+1 -1
View File
@@ -18,7 +18,7 @@
{% if app_name in "rapids longs anatomy" %} {% 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 %} {% if request.user.is_staff %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}">Mark answers</a>{% endif %}
{% 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 %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
@@ -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>. 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 %} {% 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 %} {% if unmarked %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
The following questions need marking 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> <a href="{% url exam.app_name|add:':mark' exam.pk exam_index %}">{{ exam_index|add:1 }}</a>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div id="stats-block"> <div id="stats-block">
<h3>Stats</h3> <h3>Stats</h3>
Candidates: {{cids|length}}<br /> Candidates: {{cids|length}}<br />
Questions: <span id="question-number">{{question_number}}</span><br />
Max score: {{max_score}}<br /> Max score: {{max_score}}<br />
Mean: {{mean}}, Median {{median}}, Mode {{mode}} Mean: {{mean}}, Median {{median}}, Mode {{mode}}
@@ -34,7 +50,7 @@
<th>Normalised Score</th> <th>Normalised Score</th>
</tr> </tr>
{% for cid in cids %} {% for cid in cids %}
<tr> <tr class="candidate-row" data-answer-count={{user_answer_count|get_item:cid}}>
{% if cid|slice:":1" == "u" %} {% if cid|slice:":1" == "u" %}
<td>{{cids_user_id_map|get_item:cid}}</td> <td>{{cids_user_id_map|get_item:cid}}</td>
{% else %} {% else %}
@@ -113,3 +129,27 @@
</div> </div>
{% endblock %} {% 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 'accounts_bulk_create' %}">Bulk create users</a>
<a href="{% url 'create_user' %}">Create single user</a> <a href="{% url 'create_user' %}">Create single user</a>
<details>
<summary>
Bulk edit
</summary>
<button>Delete supervisors</button>
</details>
{% endblock %} {% endblock %}
+15 -4
View File
@@ -181,13 +181,13 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
), ),
path( path(
"exam/<int:pk>/scores", "exam/<int:pk>/scores",
generic_exam_view.exam_scores_cid, generic_exam_view.exam_scores_all,
name="exam_scores_cid", name="exam_scores_all",
), ),
path( path(
"exam/<int:pk>/scores/refresh", "exam/<int:pk>/scores/refresh",
generic_exam_view.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", name="exam_scores_refresh",
), ),
path( path(
@@ -217,7 +217,7 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
), ),
path( path(
"exam/submit", "exam/submit",
generic_exam_view.postExamAnswers, generic_exam_view.post_exam_answers,
name="exam_answers_submit", name="exam_answers_submit",
), ),
path("exam/", generic_exam_view.exam_list, name="exam_list"), 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.active_exams, name="active_exams"),
path("exam/json/<int:pk>", generic_exam_view.exam_json, name="exam_json"), 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( path(
"exam/json/<int:pk>/unbased", "exam/json/<int:pk>/unbased",
generic_exam_view.exam_json_unbased, generic_exam_view.exam_json_unbased,
name="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( path(
"exam/json/<int:pk>/<int:sk>", "exam/json/<int:pk>/<int:sk>",
generic_exam_view.exam_question_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, generic_exam_view.exam_json_recreate,
name="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 return urlpatterns
+147 -32
View File
@@ -17,7 +17,7 @@ from django.utils.html import format_html
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.core.exceptions import PermissionDenied, FieldError 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.http import HttpResponseRedirect, HttpResponse
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
@@ -58,7 +58,7 @@ from .forms import (
UserUserGroupForm, 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 Rapid as RapidQuestion
from rapids.models import Exam as RapidExam from rapids.models import Exam as RapidExam
@@ -87,6 +87,9 @@ from django.db.models import Prefetch
class AuthorRequiredMixin(object): class AuthorRequiredMixin(object):
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
obj = super().get_object(*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(): if self.request.user not in obj.author.all():
raise PermissionDenied() # or Http404 raise PermissionDenied() # or Http404
return obj return obj
@@ -861,12 +864,24 @@ class ExamViews(View, LoginRequiredMixin):
"simple_content": format_html( "simple_content": format_html(
"""Answer scores updated <br/> """Answer scores updated <br/>
<a href='{}'>Return</a>""", <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): 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) exam = get_object_or_404(self.Exam, pk=exam_id)
# if not request.user.groups.filter(name="cid_user_manager").exists(): # if not request.user.groups.filter(name="cid_user_manager").exists():
@@ -874,7 +889,6 @@ class ExamViews(View, LoginRequiredMixin):
if request.user not in exam.author.all(): if request.user not in exam.author.all():
if not request.user.is_superuser: 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_users = exam.valid_cid_users.all()
@@ -883,10 +897,13 @@ class ExamViews(View, LoginRequiredMixin):
user_users = exam.valid_user_users.all() user_users = exam.valid_user_users.all()
user_user_count = user_users.count() user_user_count = user_users.count()
user_exam_data = exam.cid_users.all()
context = { context = {
"exam": exam, "exam": exam,
"cid_users": cid_users, "cid_users": cid_users,
"cid_user_count": cid_user_count, "cid_user_count": cid_user_count,
"user_exam_data": user_exam_data,
"user_users": user_users, "user_users": user_users,
"user_user_count": user_user_count, "user_user_count": user_user_count,
"app_name": self.app_name, "app_name": self.app_name,
@@ -903,7 +920,6 @@ class ExamViews(View, LoginRequiredMixin):
if not request.user.groups.filter(name="cid_user_manager").exists(): if not request.user.groups.filter(name="cid_user_manager").exists():
# raise PermissionDenied # raise PermissionDenied
if request.user not in exam.author.all(): 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() current_user_users = exam.valid_user_users.all()
@@ -937,7 +953,6 @@ class ExamViews(View, LoginRequiredMixin):
if not request.user.groups.filter(name="cid_user_manager").exists(): if not request.user.groups.filter(name="cid_user_manager").exists():
# raise PermissionDenied # raise PermissionDenied
if request.user not in exam.author.all(): 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() current_cid_users = exam.valid_cid_users.all()
@@ -980,6 +995,29 @@ class ExamViews(View, LoginRequiredMixin):
return notes 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) @method_decorator(login_required)
def exam_json_edit(self, request, pk): def exam_json_edit(self, request, pk):
if request.method == "POST": 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) exams = self.Exam.objects.filter(archive=False)
active_exams = {"exams": []} active_exams = {"exams": []}
@@ -1264,11 +1302,12 @@ class ExamViews(View, LoginRequiredMixin):
return active_exams["exams"] return active_exams["exams"]
return JsonResponse({"status": "invalid"}, status=401) return JsonResponse({"status": "invalid"}, status=401)
exam: ExamBase
for exam in exams: for exam in exams:
if exam.active or self.check_user_access(request.user, exam.pk): 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( 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") print(exam.name, "fail")
continue continue
@@ -1278,7 +1317,7 @@ class ExamViews(View, LoginRequiredMixin):
else: else:
creation_time = "None" 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 # hacky
if not based: if not based:
url = url + "/unbased" url = url + "/unbased"
@@ -1310,7 +1349,7 @@ class ExamViews(View, LoginRequiredMixin):
return JsonResponse(active_exams) return JsonResponse(active_exams)
@method_decorator(csrf_exempt) @method_decorator(csrf_exempt)
def postExamAnswers(self, request): def post_exam_answers(self, request):
if request.method == "POST": if request.method == "POST":
n = 0 n = 0
@@ -1486,32 +1525,42 @@ class ExamViews(View, LoginRequiredMixin):
return JsonResponse({"success": True, "question_count": n}) return JsonResponse({"success": True, "question_count": n})
return JsonResponse({"success": False, "error": "Invalid data"}) 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): if not exam.active and not self.check_user_access(request.user, pk):
raise Http404("No available exam") raise Http404("No available exam")
time = datetime.now() # TODO: check access for logged in users
exam_json = exam.get_exam_json(based=False) # Check access for users
exam_json["generated"] = time.isoformat() if request.user.is_anonymous:
exam_json["exam_json_id"] = exam.exam_json_id user_id = None
# exam_json["exam_active"] = False else:
return JsonResponse(exam_json) user_id = request.user.pk
if not exam.check_cid_user(cid, passcode, request, user_id):
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):
raise Http404("No available exam") raise Http404("No available exam")
# exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk)) # 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) 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) 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) 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): def exam_question_json(self, request, pk, sk):
question = get_object_or_404(self.Question, pk=sk) question = get_object_or_404(self.Question, pk=sk)
exam = get_object_or_404(self.Exam, pk=pk) exam = get_object_or_404(self.Exam, pk=pk)
@@ -1687,12 +1762,29 @@ class ExamViews(View, LoginRequiredMixin):
template_context, 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) exam = get_object_or_404(self.Exam, pk=pk)
if not exam.exam_mode: if not exam.exam_mode:
raise Http404("Packet not in 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_and_marks = defaultdict(list)
user_answers_marks = defaultdict(list) user_answers_marks = defaultdict(list)
user_answers = defaultdict(list) user_answers = defaultdict(list)
@@ -1704,6 +1796,10 @@ class ExamViews(View, LoginRequiredMixin):
cached_scores = True 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"): if self.app_name in ("rapids"):
user_answers_callstates = defaultdict(list) user_answers_callstates = defaultdict(list)
user_answers_callstates_counted = {} user_answers_callstates_counted = {}
@@ -1719,8 +1815,13 @@ class ExamViews(View, LoginRequiredMixin):
question__in=questions, exam__id=pk question__in=questions, exam__id=pk
) )
question_number = questions.count()
cids = set() cids = set()
plain_cids = set()
user_ids = set()
cids_user_id_map = {} cids_user_id_map = {}
# Loop through all candidates # Loop through all candidates
@@ -1731,6 +1832,7 @@ class ExamViews(View, LoginRequiredMixin):
cids_user_id_map[cid] = cid cids_user_id_map[cid] = cid
# cid_passcodes[cid] = cid_user_answer.passcode # cid_passcodes[cid] = cid_user_answer.passcode
cids.add(cid) cids.add(cid)
plain_cids.add(cid_user_answer.cid)
else: else:
cid = f"u/{cid_user_answer.user.pk}" cid = f"u/{cid_user_answer.user.pk}"
# cids_user_id_map[cid] = cid_user_answer.user.username # cids_user_id_map[cid] = cid_user_answer.user.username
@@ -1739,6 +1841,7 @@ class ExamViews(View, LoginRequiredMixin):
name = cid_user_answer.user.username name = cid_user_answer.user.username
cids_user_id_map[cid] = name cids_user_id_map[cid] = name
cids.add(cid) cids.add(cid)
user_ids.add(cid_user_answer.user.pk)
s = cid_user_answer s = cid_user_answer
# user_names[cid] = cid # user_names[cid] = cid
@@ -1773,6 +1876,7 @@ class ExamViews(View, LoginRequiredMixin):
user_scores = {} user_scores = {}
user_scores_normalised = {} user_scores_normalised = {}
user_answer_count = {}
for user in user_answers_marks: for user in user_answers_marks:
if self.app_name in ("rapids", "anatomy", "sbas"): if self.app_name in ("rapids", "anatomy", "sbas"):
@@ -1791,15 +1895,17 @@ class ExamViews(View, LoginRequiredMixin):
else: else:
user_scores_normalised[user] = user_scores[user] user_scores_normalised[user] = user_scores[user]
user_answer_count[user] = len(user_answers_marks[user])
# ignore scores of 0 for stats # ignore scores of 0 for stats
user_scores_list = [i for i in user_scores.values() if i > 0] user_scores_list = [i for i in user_scores.values() if i > 0]
if self.app_name in ("rapids", "anatomy"): if self.app_name in ("rapids", "anatomy"):
max_score = len(questions) * 2 max_score = question_number * 2
elif self.app_name == "physics": elif self.app_name == "physics":
max_score = len(questions) * 5 max_score = question_number * 5
else: else:
max_score = len(questions) max_score = question_number
if len(user_scores_list) < 1: if len(user_scores_list) < 1:
mean = 0 mean = 0
@@ -1856,10 +1962,13 @@ class ExamViews(View, LoginRequiredMixin):
template_variables = { template_variables = {
"cids": sorted(cids), "cids": sorted(cids),
"missing_cids": valid_cid_users - plain_cids,
"missing_users": valid_user_users - user_ids,
# "cid_passcodes": cid_passcodes, # "cid_passcodes": cid_passcodes,
"exam": exam, "exam": exam,
"unmarked": unmarked, "unmarked": unmarked,
"questions": questions, "questions": questions,
"question_number": question_number,
"by_question": by_question, "by_question": by_question,
# "user_answers": dict(user_answers), # "user_answers": dict(user_answers),
# "user_answers_marks": dict(user_answers_marks), # "user_answers_marks": dict(user_answers_marks),
@@ -1868,6 +1977,7 @@ class ExamViews(View, LoginRequiredMixin):
# "user_scores_list": user_scores_list, # "user_scores_list": user_scores_list,
# "user_names": user_names, # "user_names": user_names,
# "user_answers_and_marks": user_answers_and_marks, # "user_answers_and_marks": user_answers_and_marks,
"user_answer_count": user_answer_count,
"cids_user_id_map": cids_user_id_map, "cids_user_id_map": cids_user_id_map,
"max_score": max_score, "max_score": max_score,
"mean": mean, "mean": mean,
@@ -2001,15 +2111,20 @@ class ExamCloneMixin:
old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"]) old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"])
initial_data = model_to_dict(old_object, exclude=["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) 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.exam_questions = list(questions)
self.author = list(authors)
return initial_data return initial_data
def form_valid(self, form): def form_valid(self, form):
object = form.save() object = form.save()
# Reapply these otherwise they get lost?
object.exam_questions.set(self.exam_questions) object.exam_questions.set(self.exam_questions)
object.author.set(self.author)
object.save() object.save()
return HttpResponseRedirect(object.get_absolute_url()) return HttpResponseRedirect(object.get_absolute_url())
+4
View File
@@ -24,6 +24,7 @@ def image_as_base64(image_file):
# if not os.path.isfile(image_file): # if not os.path.isfile(image_file):
# return None # return None
try:
encoded_string = "" encoded_string = ""
# with open(image_file, 'rb') as img_f: # with open(image_file, 'rb') as img_f:
# encoded_string = base64.b64encode(img_f.read()) # encoded_string = base64.b64encode(img_f.read())
@@ -35,6 +36,9 @@ def image_as_base64(image_file):
if "dicom" in mimetype or "octet-stream" in mimetype: if "dicom" in mimetype or "octet-stream" in mimetype:
return "data:{};base64,{}".format(mimetype, encoded_string.decode("utf-8")) return "data:{};base64,{}".format(mimetype, encoded_string.decode("utf-8"))
return "data:image/{};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 ( from generic.models import (
CidUser, CidUser,
CidUserGroup, CidUserGroup,
ExamUserStatus,
Examination, Examination,
Condition, Condition,
Sign, Sign,
@@ -627,6 +628,8 @@ class Exam(ExamBase):
related_name="longs_user_user_groups", related_name="longs_user_user_groups",
) )
exam_user_status = GenericRelation(ExamUserStatus)
def get_exam_question_json(self, question_id): def get_exam_question_json(self, question_id):
q = get_object_or_404(Long, pk=question_id) q = get_object_or_404(Long, pk=question_id)
+1 -1
View File
@@ -6,6 +6,6 @@
Exams: {{exam.name}}-> Exams: {{exam.name}}->
<a href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a> / <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: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> / <a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> /
{% endblock %} {% endblock %}
+1 -1
View File
@@ -88,7 +88,7 @@ urlpatterns = [
views.refresh_exam_question_json, views.refresh_exam_question_json,
name="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( path(
"exam/<int:pk>/scores/<int:cid>/<str:passcode>/", "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
views.exam_scores_cid_user, views.exam_scores_cid_user,
+1 -1
View File
@@ -862,7 +862,7 @@ def mark_question_overview(request, exam_id, sk):
@login_required @login_required
@user_is_long_marker @user_is_long_marker
def exam_scores_cid(request, pk): def exam_scores_all(request, pk):
exam = get_object_or_404(Exam, pk=pk) exam = get_object_or_404(Exam, pk=pk)
questions = exam.exam_questions.all() 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 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 import reversion
@@ -175,6 +175,8 @@ class Exam(ExamBase):
related_name="physics_user_user_groups" related_name="physics_user_user_groups"
) )
exam_user_status = GenericRelation(ExamUserStatus)
def get_take_url(self): def get_take_url(self):
return reverse("physics:exam_start", kwargs={"pk": self.pk}) return reverse("physics:exam_start", kwargs={"pk": self.pk})
+1 -1
View File
@@ -4,6 +4,6 @@
{{block.super}} {{block.super}}
<br/> <br/>
Exams: {{exam.name}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a> / 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> <a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a>
{% endblock %} {% 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_1000 = CidUser.objects.create(cid=1000, passcode="ABCD", group=group1)
cid_user_1001 = CidUser.objects.create(cid=1001, passcode="EFGH", 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_1000.cid == 1000
assert cid_user_1001.cid == 1001 assert cid_user_1001.cid == 1001
+5 -5
View File
@@ -287,16 +287,16 @@ def exam_submit(request):
try: try:
if exam_type == "anatomy": if exam_type == "anatomy":
return AnatomyExamViews.postExamAnswers(request) return AnatomyExamViews.post_exam_answers(request)
elif exam_type == "rapid": elif exam_type == "rapid":
return RapidsExamsViews.postExamAnswers(request) return RapidsExamsViews.post_exam_answers(request)
elif exam_type == "long": elif exam_type == "long":
return LongsExamViews.postExamAnswers(request) return LongsExamViews.post_exam_answers(request)
return JsonResponse({"success": False, "error": "Invalid exam type"}) return JsonResponse({"success": False, "error": "Invalid exam type"})
except ObjectDoesNotExist: except ObjectDoesNotExist:
return JsonResponse({"success": False, "error": "Invalid data"}) return JsonResponse({"success": False, "error": "Invalid data"})
# postExamAnswers # post_exam_answers
return JsonResponse({"success": False, "error": "Invalid data"}) return JsonResponse({"success": False, "error": "Invalid data"})
@@ -405,7 +405,7 @@ def answer_suggestion_submit(request):
a.save() a.save()
return JsonResponse({"success": True, "error": "Answer submited"}) return JsonResponse({"success": True, "error": "Answer submited"})
# postExamAnswers # post_exam_answers
return JsonResponse({"success": False, "error": "Invalid data"}) 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 ( from generic.models import (
CidUser, CidUser,
CidUserGroup, CidUserGroup,
ExamUserStatus,
Site, Site,
Condition, Condition,
Sign, Sign,
@@ -643,6 +644,8 @@ class Exam(ExamBase):
related_name="rapid_user_user_groups" related_name="rapid_user_user_groups"
) )
exam_user_status = GenericRelation(ExamUserStatus)
def get_normal_abnormal_breakdown(self): def get_normal_abnormal_breakdown(self):
# Inefficient but more extendible # Inefficient but more extendible
questions = self.exam_questions.all() questions = self.exam_questions.all()
+1 -1
View File
@@ -7,7 +7,7 @@
<a href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a> / <a href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %} {% if exam.exam_mode %}
<a href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a> / <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> / <a href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a> /
{% endif %} {% endif %}
<a href="{% url 'rapids:rapid_create_exam' pk=exam.pk %}">Add New Question</a> <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 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 import reversion
@@ -198,6 +198,8 @@ class Exam(ExamBase):
related_name="sba_user_user_groups" related_name="sba_user_user_groups"
) )
exam_user_status = GenericRelation(ExamUserStatus)
def get_take_url(self): def get_take_url(self):
return reverse("sbas:exam_start", kwargs={"pk": self.pk}) return reverse("sbas:exam_start", kwargs={"pk": self.pk})
+1 -1
View File
@@ -5,6 +5,6 @@
<br/> <br/>
Exams: {{exam.name}}-> Exams: {{exam.name}}->
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">Overview</a> / <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> / <a href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a> /
{% endblock %} {% 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> <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 %} {% 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_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 %}> <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> <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 %}> <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> <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 %} {% 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_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 %}> <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> <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 %}> <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 %}