lost track of changes...
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+40
@@ -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
@@ -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):
|
||||||
return reverse("{}:exam_json".format(self.app_name), args=(self.pk,))
|
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):
|
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:
|
||||||
return True
|
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 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"),
|
||||||
|
|||||||
@@ -1,49 +1,71 @@
|
|||||||
|
|
||||||
<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>
|
||||||
{% for cid in cid_users %}
|
{% for cid in cid_users %}
|
||||||
|
|
||||||
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
|
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
|
||||||
Email: {{cid.email}}
|
Email: {{cid.email}}
|
||||||
{% if cid.supervisor %} / Supervisor email: {{cid.supervisor.email}}{% endif %} <br />
|
{% if cid.supervisor %} / Supervisor email: {{cid.supervisor.email}}{% endif %} <br />
|
||||||
Internal candidate: {{cid.internal_candidate}}
|
Internal candidate: {{cid.internal_candidate}}
|
||||||
{% if cid.login_email_sent %} / Login email sent{% endif %}
|
{% if cid.login_email_sent %} / Login email sent{% endif %}
|
||||||
{% if cid.results_email_sent %} / Results email sent{% endif %}
|
{% if cid.results_email_sent %} / Results email sent{% endif %}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% 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>
|
||||||
{% for user in user_users %}
|
{% for user in user_users %}
|
||||||
|
|
||||||
<li>{{user.username}}
|
<li>{{user.username}}
|
||||||
Email: {{user.email}}
|
Email: {{user.email}}
|
||||||
</li>
|
</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 %}
|
{% endif %}
|
||||||
<a href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -90,22 +106,22 @@
|
|||||||
<details>
|
<details>
|
||||||
<summary>Email results</summary>
|
<summary>Email results</summary>
|
||||||
User results emailed: {{exam.exam_results_emailed|default:"Never"}}<br/>
|
User results emailed: {{exam.exam_results_emailed|default:"Never"}}<br/>
|
||||||
<button id="email-results-button"
|
<button id="email-results-button"
|
||||||
title="Email results to users and their supervisor"
|
title="Email results to users and their supervisor"
|
||||||
hx-get="{% url exam.app_name|add:':exam_report_email' exam_id=exam.pk %}"
|
hx-get="{% url exam.app_name|add:':exam_report_email' exam_id=exam.pk %}"
|
||||||
hx-target="#user-details"
|
hx-target="#user-details"
|
||||||
hx-confirm="This will email results, please make sure scores have been refreshed before continuing"
|
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-prompt="Please enter an additional email to send to (user and supervisor will automatically be used if available) if required"
|
||||||
>Email user results</button>
|
>Email user results</button>
|
||||||
<button id="email-unsent-results-button" title="Email results to users and their supervisor"
|
<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-get="{% url exam.app_name|add:':exam_report_email_unsent' exam_id=exam.pk %}"
|
||||||
hx-target="#user-details"
|
hx-target="#user-details"
|
||||||
hx-confirm="This will email results, please make sure scores have been refreshed before continuing"
|
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-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>
|
>Email unsent user results</button>
|
||||||
<button id="email-results-check-button" title="Check the status of emailed results"
|
<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-get="{% url exam.app_name|add:':exam_report_email_status' exam_id=exam.pk %}"
|
||||||
hx-target="#user-details">Check email status</button>
|
hx-target="#user-details">Check email status</button>
|
||||||
<p>Note: currently only works with registered users</p>
|
<p>Note: currently only works with registered users</p>
|
||||||
<div id="user-details"></div>
|
<div id="user-details"></div>
|
||||||
</details>
|
</details>
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+150
-35
@@ -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,8 +889,7 @@ 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()
|
||||||
cid_user_count = cid_users.count()
|
cid_user_count = cid_users.count()
|
||||||
@@ -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,8 +920,7 @@ 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,8 +953,7 @@ 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())
|
||||||
|
|
||||||
|
|||||||
+15
-11
@@ -24,17 +24,21 @@ def image_as_base64(image_file):
|
|||||||
# if not os.path.isfile(image_file):
|
# if not os.path.isfile(image_file):
|
||||||
# return None
|
# return None
|
||||||
|
|
||||||
encoded_string = ""
|
try:
|
||||||
# with open(image_file, 'rb') as img_f:
|
encoded_string = ""
|
||||||
# encoded_string = base64.b64encode(img_f.read())
|
# with open(image_file, 'rb') as img_f:
|
||||||
encoded_string = base64.b64encode(image_file.file.read())
|
# encoded_string = base64.b64encode(img_f.read())
|
||||||
mimetype, enc = mimetypes.guess_type(image_file.path)
|
encoded_string = base64.b64encode(image_file.file.read())
|
||||||
# Treat unknown files as dicom
|
mimetype, enc = mimetypes.guess_type(image_file.path)
|
||||||
if None == mimetype:
|
# Treat unknown files as dicom
|
||||||
return "data:application/dicom;base64,{}".format(encoded_string.decode("utf-8"))
|
if None == mimetype:
|
||||||
if "dicom" in mimetype or "octet-stream" in mimetype:
|
return "data:application/dicom;base64,{}".format(encoded_string.decode("utf-8"))
|
||||||
return "data:{};base64,{}".format(mimetype, encoded_string.decode("utf-8"))
|
if "dicom" in mimetype or "octet-stream" in mimetype:
|
||||||
return "data:image/{};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"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAABitJREFUeF7t3EsofFEcB/CfkjSlkaImWXiV8oqQPJJigZQFkoUSSh4bGwsprzxCxE4WFiRbZOERC2yMdyE0GxmyUBZEkn/n/pvxmrlzH+c5c2/pP3/nzv2d8/2ce47H/3/9rFbrZ0hICAQGBoJxsEvg9fUVHh8fwc9ms32iFzExMWA2m9n1yIcrPz09wfX1NaAbw89ut3+aTCbpEwYK/VnhwEDZv7y8/AexWCzwvcG4U+jA/M787u7uCwR1wUChA+Eu6z8gBgodEHcT3yWIgUIWRW4VcgtioJBB8bQlyIIYKHhRPGGgah5BDBQ8KEowFIMYKPpQlGKoAjFQtKGowVANYqCoQ1GLoQnEQFGGogVDM4iBIo+iFUMXiIHiGkUPhm4QA+Unil4MLCAGyn8UHBjYQHB2SNmWyddZuDCwgvgqCk4M7CC+hoIbgwiIr6CQwCAG4u0opDCIgngrCkkM4iDehkIagwqIt6DQwKAGIjoKLQyqIKKi0MSgDiIaCm0MJiCioLDAYAbCOworDKYgvKKwxGAOwhsKawwuQHhB4QGDGxDWKLxgcAXCCoUnDO5AaKPwhsElCC0UHjG4BSGNwisG1yCkUHjG4B4ENwrvGEKA4EIRAUMYEL0oomAIBaIVRSQM4UDUooiGISSIUhQRMYQF8YQiKobQIO5QRMYQHuQ3Cvq76E80UvT/1NFAeT4cdwXqo+iPlzJAOJtpwoN83zOMJYvx7HK1gRubOiMUueBFRhFyyVISuJJzGM0l2bLCgagJWs25vOAIBaIlYC3vYYkjDIieYPW8lzaOECA4AsVxDRo43IPgDBLntUjhcA1CIkAS18SJwy0IyeBIXlsvDpcgNAKjUUMLDncgNIOiWUspDlcgLAJiUVMOhxsQlsGwrP0bhwsQHgLhoQ9c/AqXlyA8/cMJpXuA3vOY3iE8YTiCZN0nZiCsBy43k1n2jQkIywErXVJY9ZE6CKuBKoX4fh6LvlIFYTFALRAsUaiBiIjBYqOnAiIyBm0U4iDegEEThSiIN2HQQiEG4o0YNFCIgHgzBmkU7CC+gEESBSuIL2GQQsEG4osYJFCwgPgyBm4U3SAGxtcPWnBkoQsERwf0/qyJt/frzUQziN7CvAWJsz96stEEoqcgzoHzfC2tGakG0VqI5/BI9U1LVqpAtBQgNVhRrqs2M8Ugai/MKrCZmRloaWmBwcFB6U/Hsb+/D42NjfDw8AABAQHQ29sLVVVVUrNcm6dxKKnn7+8P9fX10NTUBGazWbaeIhBRMNra2uD8/FwKvba21gny9vYG0dHRMDY2BhUVFXB2dgY5OTmwu7sLkZGRbtvi4uJkPdTUy87OhunpacjNzYXU1FSXfUH1PIKIgoGSW11dhcLCQumjrKzMCbK2tgatra1wcXHhDLimpgaioqIABeWuLTw8HCYnJ+Hg4ADQLEd3w9DQEBweHkJgYKDqeuh6ERERMD4+DpeXl3/60tXVJQ8iEsb3qVxQUPADZGJiAtbX12FxcdF5Wl9fH5yenkog7toWFhagpKQE8vLyoK6uDhISEmBpaQnS0tJ+3Dlq6qG7Y2VlReoLWr7Q4egLquf2DhEVAw3wd0ADAwNwdHQEaMCOY3R0FDY3NyUQd23Ly8tSQBkZGZCcnCxBoFn8+1Bbb29vDzo6OpzPZXH0xVHPz263f1osFmcdkTFcgaBlBy1naHY7js7OTunJQVlZWW7b5ufnpdObm5thamoKbm9vISwszCOIknqzs7POJxeNjIxIr1G9P3eI6BiuQDY2NqSvsK6urpxhlpeXQ0pKCmRmZrptQ7MYLWtFRUVQXFwMz8/PMDc35xFEaT1H1j09PdJdiOr9APEGDFcg7+/vEBsbC/39/VBdXQ3Hx8eQn58vbdZoo5VrS09Ph/b2dmlPSkpKArT8IUy5PUtNvZ2dHQl7e3sbEhMTv0BMJpPQD//6+PiA+Ph4KaebmxsICgqC4OBg6XsNtO4jhIaGBri/vwc01uHhYSgtLZXOd9eGZuzJyYlzqdva2oLKykrpc6GhodjqdXd3S/sJ+nh5eQE/m832+fj4KPzDvzx9E8dzu2N1CgkJAT+r1fqJXqCvrY2DXQKvr6+Abox/Sd/NNVCznSgAAAAASUVORK5CYII="
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user