Finished client result API.
Need to work on adjustment user codes and server email notifications.
This commit is contained in:
parent
dd7df3080e
commit
288ecb60e1
@ -47,14 +47,8 @@ class UpdateAccountForm(FlaskForm):
|
||||
|
||||
class CreateTest(FlaskForm):
|
||||
start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() )
|
||||
time_options = [
|
||||
('none', 'None'),
|
||||
('60', '1 hour'),
|
||||
('90', '1 hour 30 minutes'),
|
||||
('120', '2 hours')
|
||||
]
|
||||
expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) )
|
||||
time_limit = SelectField('Time Limit', choices=time_options)
|
||||
time_limit = SelectField('Time Limit')
|
||||
dataset = SelectField('Question Dataset')
|
||||
|
||||
class UploadDataForm(FlaskForm):
|
||||
|
@ -14,7 +14,7 @@ class Test:
|
||||
self._id = _id
|
||||
self.start_date = start_date
|
||||
self.expiry_date = expiry_date
|
||||
self.time_limit = None if time_limit == 'none' or time_limit == '' else time_limit
|
||||
self.time_limit = None if time_limit == 'none' or time_limit == '' else int(time_limit)
|
||||
self.creator = creator
|
||||
self.dataset = dataset
|
||||
|
||||
@ -88,7 +88,7 @@ class Test:
|
||||
test['expiry_date'] = self.expiry_date
|
||||
updated.append('expiry date')
|
||||
if not self.time_limit == '' and self.time_limit is not None:
|
||||
test['time_limit'] = self.time_limit
|
||||
test['time_limit'] = int(self.time_limit)
|
||||
updated.append('time limit')
|
||||
output = ''
|
||||
if len(updated) == 0:
|
||||
|
@ -16,7 +16,7 @@ import secrets
|
||||
from main import mail
|
||||
from datetime import datetime, date, timedelta
|
||||
from .models.tests import Test
|
||||
from common.data_tools import get_default_dataset
|
||||
from common.data_tools import get_default_dataset, get_time_options
|
||||
|
||||
views = Blueprint(
|
||||
'admin_views',
|
||||
@ -339,6 +339,7 @@ def tests(filter=''):
|
||||
if filter == 'create':
|
||||
from .models.forms import CreateTest
|
||||
form = CreateTest()
|
||||
form.time_limit.choices = get_time_options()
|
||||
form.dataset.choices=available_datasets()
|
||||
form.time_limit.default='none'
|
||||
form.dataset.default=get_default_dataset()
|
||||
|
@ -1,7 +1,7 @@
|
||||
import os
|
||||
import pathlib
|
||||
from json import dump, loads
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask.json import jsonify
|
||||
from main import app
|
||||
@ -100,3 +100,92 @@ def generate_questions(dataset:dict):
|
||||
question['options'] = _question['options'].copy()
|
||||
output.append(question)
|
||||
return output
|
||||
|
||||
def evaluate_answers(dataset: dict, answers: dict):
|
||||
score = 0
|
||||
max = 0
|
||||
tags = {}
|
||||
for block in dataset['questions']:
|
||||
if block['type'] == 'question':
|
||||
max += 1
|
||||
q_no = block['q_no']
|
||||
if str(q_no) in answers:
|
||||
correct = block['correct']
|
||||
correct_answer = block['options'][correct]
|
||||
if answers[str(q_no)] == correct_answer:
|
||||
score += 1
|
||||
for tag in block['tags']:
|
||||
if tag not in tags:
|
||||
tags[tag] = {
|
||||
'scored': 1,
|
||||
'max': 1
|
||||
}
|
||||
else:
|
||||
tags[tag]['scored'] += 1
|
||||
tags[tag]['max'] += 1
|
||||
else:
|
||||
for tag in block['tags']:
|
||||
if tag not in tags:
|
||||
tags[tag] = {
|
||||
'scored': 0,
|
||||
'max': 1
|
||||
}
|
||||
else:
|
||||
tags[tag]['max'] += 1
|
||||
if block['type'] == 'block':
|
||||
for question in block['questions']:
|
||||
max += 1
|
||||
q_no = question['q_no']
|
||||
if str(q_no) in answers:
|
||||
correct = question['correct']
|
||||
correct_answer = question['options'][correct]
|
||||
if answers[str(q_no)] == correct_answer:
|
||||
score += 1
|
||||
for tag in question['tags']:
|
||||
if tag not in tags:
|
||||
tags[tag] = {
|
||||
'scored': 1,
|
||||
'max': 1
|
||||
}
|
||||
else:
|
||||
tags[tag]['scored'] += 1
|
||||
tags[tag]['max'] += 1
|
||||
else:
|
||||
for tag in question['tags']:
|
||||
if tag not in tags:
|
||||
tags[tag] = {
|
||||
'scored': 0,
|
||||
'max': 1
|
||||
}
|
||||
else:
|
||||
tags[tag]['max'] += 1
|
||||
|
||||
grade = 'merit' if score/max >= .85 else 'pass' if score/max >= .70 else 'fail'
|
||||
return {
|
||||
'grade': grade,
|
||||
'tags': tags,
|
||||
'score': score,
|
||||
'max': max
|
||||
}
|
||||
|
||||
|
||||
def get_tags_list(dataset:dict):
|
||||
output = []
|
||||
blocks = dataset['questions']
|
||||
for block in blocks:
|
||||
if block['type'] == 'question':
|
||||
output = list(set(output) | set(block['tags']))
|
||||
if block['type'] == 'block':
|
||||
for question in block['questions']:
|
||||
output = list(set(output) | set(question['tags']))
|
||||
return output
|
||||
|
||||
|
||||
def get_time_options():
|
||||
time_options = [
|
||||
('none', 'None'),
|
||||
('60', '1 hour'),
|
||||
('90', '1 hour 30 minutes'),
|
||||
('120', '2 hours')
|
||||
]
|
||||
return time_options
|
@ -37,11 +37,22 @@ def encrypt(input):
|
||||
def decrypt(input):
|
||||
if not check_keyfile_exists():
|
||||
raise EncryptionKeyMissing
|
||||
input = input.encode()
|
||||
_encryption_key = load_key()
|
||||
fernet = Fernet(_encryption_key)
|
||||
if type(input) == str:
|
||||
input = input.encode()
|
||||
output = fernet.decrypt(input)
|
||||
return output.decode()
|
||||
if type(input) == dict:
|
||||
output = {}
|
||||
for key, value in input.items():
|
||||
if type(value) == dict:
|
||||
output[key] = decrypt(value)
|
||||
else:
|
||||
value = value.encode()
|
||||
output[key] = fernet.decrypt(value)
|
||||
output[key] = output[key].decode()
|
||||
return output
|
||||
|
||||
class EncryptionKeyMissing(Exception):
|
||||
def __init__(self, message='There is no encryption keyfile.'):
|
||||
|
@ -15,7 +15,7 @@ def decrypt_find(collection:collection, query:dict):
|
||||
if not query:
|
||||
output_list.append(decrypted_document)
|
||||
else:
|
||||
if set(query.items()).issubset(set(decrypted_document.items())):
|
||||
if query.items() <= decrypted_document.items():
|
||||
output_list.append(decrypted_document)
|
||||
return output_list
|
||||
|
||||
|
@ -130,7 +130,20 @@
|
||||
|
||||
.control-button-container {
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.control-button-container a {
|
||||
width: fit-content;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.navigator-help {
|
||||
margin: 4rem auto;
|
||||
}
|
||||
|
||||
#navigator-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Layout for Mobile Devices */
|
||||
|
@ -4,6 +4,11 @@ body {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
#dismiss-cookie-alert {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background-color: lightgray;
|
||||
font-size: small;
|
||||
@ -133,6 +138,31 @@ body {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.results-name {
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.results-name .surname {
|
||||
font-variant: small-caps;
|
||||
font-size: 24pt;
|
||||
}
|
||||
|
||||
.results-score {
|
||||
margin: 2rem auto;
|
||||
width: fit-content;
|
||||
font-size: 36pt;
|
||||
}
|
||||
|
||||
.results-score::after {
|
||||
content: '%';
|
||||
}
|
||||
|
||||
.results-grade {
|
||||
margin: 2rem auto;
|
||||
width: fit-content;
|
||||
font-size: 26pt;
|
||||
}
|
||||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
|
@ -1,4 +1,4 @@
|
||||
// Click Listeners
|
||||
// Bind Listeners
|
||||
|
||||
$("input[name='font-select']").change(function(){
|
||||
let $choice = $(this).val();
|
||||
@ -16,6 +16,8 @@ $("input[name='bg-select']").change(function(){
|
||||
});
|
||||
|
||||
$("#btn-toggle-navigator").click(function(event){
|
||||
check_answered();
|
||||
update_navigator();
|
||||
if ($quiz_navigator.is(":hidden")) {
|
||||
if ($quiz_settings.is(":visible")) {
|
||||
toggle_settings = true;
|
||||
@ -23,6 +25,8 @@ $("#btn-toggle-navigator").click(function(event){
|
||||
}
|
||||
$quiz_render.hide();
|
||||
$quiz_navigator.show();
|
||||
$(".navigator-text").show();
|
||||
$(".review-text").hide();
|
||||
toggle_navigator = false;
|
||||
} else {
|
||||
$quiz_navigator.hide();
|
||||
@ -57,12 +61,35 @@ $("#btn-toggle-settings").click(function(event){
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
$(".btn-quiz-return").click(function(event){
|
||||
$quiz_navigator.hide();
|
||||
$quiz_settings.hide();
|
||||
$quiz_render.show();
|
||||
toggle_settings = false;
|
||||
toggle_navigator = false;
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
$(".btn-dummy").click(function(event){
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
$("#navigator-container").on("click", ".q-navigator-button", function(event){
|
||||
check_answered();
|
||||
update_navigator();
|
||||
current_question = parseInt($(this).attr("name"));
|
||||
$quiz_render.show();
|
||||
$quiz_navigator.hide();
|
||||
toggle_navigator = false;
|
||||
toggle_settings = false;
|
||||
render_question();
|
||||
check_flag();
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
$(".q-question-nav").click(function(event){
|
||||
check_answered();
|
||||
update_navigator();
|
||||
if ($(this).attr("id") == "q-nav-next") {
|
||||
if (current_question < questions.length) {
|
||||
current_question ++;
|
||||
@ -71,6 +98,12 @@ $(".q-question-nav").click(function(event){
|
||||
if (current_question > 0) {
|
||||
current_question --;
|
||||
}
|
||||
} else if ($(this).hasClass("q-navigator-button")) {
|
||||
current_question = $(this).attr("name");
|
||||
$quiz_render.show();
|
||||
$quiz_navigator.hide();
|
||||
toggle_navigator = false;
|
||||
toggle_settings = false;
|
||||
}
|
||||
render_question();
|
||||
check_flag();
|
||||
@ -81,10 +114,14 @@ $("#q-nav-flag").click(function(event){
|
||||
if (question_status[current_question] != 1) {
|
||||
question_status[current_question] = 1;
|
||||
$(this).removeClass().addClass("btn btn-warning");
|
||||
$(this).attr("title", "Question Flagged for revision. Click to un-flag.");
|
||||
} else {
|
||||
question_status[current_question] = 0;
|
||||
$(this).removeClass().addClass("btn btn-secondary");
|
||||
$(this).attr("title", "Question Un-Flagged. Click to flag for revision.");
|
||||
}
|
||||
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||
update_navigator();
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
@ -102,12 +139,22 @@ $("#btn-start-quiz").click(function(event){
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
contentType: "application/json",
|
||||
success: function(response) {
|
||||
time_limit_data = response.time_limit;
|
||||
time_limit = response.time_limit;
|
||||
start_time = response.start_time;
|
||||
questions = response.questions;
|
||||
total_questions = questions.length;
|
||||
window.localStorage.setItem('questions', JSON.stringify(questions));
|
||||
window.localStorage.setItem('start_time', JSON.stringify(start_time));
|
||||
window.localStorage.setItem('time_limit', JSON.stringify(time_limit));
|
||||
render_question();
|
||||
build_navigator();
|
||||
check_flag();
|
||||
if (time_limit != 'null') {
|
||||
time_remaining = get_time_remaining();
|
||||
clock = setInterval(timer, 1000);
|
||||
} else {
|
||||
$("#q-timer-widget").hide();
|
||||
}
|
||||
},
|
||||
error: function(response) {
|
||||
console.log(response);
|
||||
@ -117,6 +164,79 @@ $("#btn-start-quiz").click(function(event){
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
$("#quiz-question-options").on("change", ".quiz-option", function(event){
|
||||
$name = parseInt($(this).attr("name"));
|
||||
$value = $(this).attr("value");
|
||||
answers[$name] = $value;
|
||||
window.localStorage.setItem('answers', JSON.stringify(answers));
|
||||
});
|
||||
|
||||
$("#q-review-answers").click(function(event){
|
||||
check_answered();
|
||||
update_navigator();
|
||||
if ($quiz_navigator.is(":hidden")) {
|
||||
if ($quiz_settings.is(":visible")) {
|
||||
toggle_settings = true;
|
||||
$quiz_settings.hide();
|
||||
}
|
||||
$quiz_render.hide();
|
||||
$quiz_navigator.show();
|
||||
$(".navigator-text").hide();
|
||||
$(".review-text").show();
|
||||
toggle_navigator = false;
|
||||
} else {
|
||||
$quiz_navigator.hide();
|
||||
if (toggle_settings) {
|
||||
$quiz_settings.show();
|
||||
toggle_settings = false;
|
||||
} else {
|
||||
$quiz_render.show();
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
$(".quiz-button-submit").click(function(event){
|
||||
let submission = {
|
||||
'_id': _id,
|
||||
'answers': answers
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: `/api/submit/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify(submission),
|
||||
contentType: "application/json",
|
||||
success: function(response) {
|
||||
window.localStorage.clear();
|
||||
window.location.href = `/result/`;
|
||||
},
|
||||
error: function(response) {
|
||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||
alert.innerHTML = alert.innerHTML + `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
} else if (response.responseJSON.error instanceof Array) {
|
||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
alert.innerHTML = alert.innerHTML + `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Functions
|
||||
|
||||
function set_font(value = 'osdefault') {
|
||||
@ -229,41 +349,204 @@ function render_question() {
|
||||
$question_text.html(question.text);
|
||||
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`);
|
||||
|
||||
var q_no = question['q_no'];
|
||||
var options = question.options;
|
||||
var options_output = '';
|
||||
for (let i = 0; i < options.length; i ++) {
|
||||
var add_checked = ''
|
||||
if (q_no in answers) {
|
||||
if (answers[q_no] == options[i]) {
|
||||
add_checked = 'checked';
|
||||
}
|
||||
}
|
||||
options_output += `<div class="form-check">
|
||||
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${question.q_no}" value="${options[i]}">
|
||||
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i]}" ${add_checked}>
|
||||
<label for="q${current_question}-${i}" class="form-check-label">${options[i]}</label>
|
||||
</div>`;
|
||||
}
|
||||
$question_options.html(options_output);
|
||||
$question_title.focus();
|
||||
}
|
||||
|
||||
function check_answered() {
|
||||
var question = questions[current_question];
|
||||
var name = question.q_no;
|
||||
if (!$(`input[name='${name}']:checked`).val() && question_status[current_question] == 0) {
|
||||
if (question_status[current_question] == 0) {
|
||||
if (!$(`input[name='${name}']:checked`).val()) {
|
||||
question_status[current_question] = -1;
|
||||
} else {
|
||||
question_status[current_question] = 2;
|
||||
}
|
||||
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||
}
|
||||
}
|
||||
|
||||
function check_flag() {
|
||||
if (!(current_question in question_status)) {
|
||||
question_status[current_question] = 0;
|
||||
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||
}
|
||||
switch (question_status[current_question]) {
|
||||
case -1:
|
||||
$nav_flag.removeClass().addClass('btn btn-danger');
|
||||
$nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped');
|
||||
$nav_flag.attr("title", "Question Incomplete. Click to flag for revision.");
|
||||
break;
|
||||
case 1:
|
||||
$nav_flag.removeClass().addClass('btn btn-warning');
|
||||
$nav_flag.attr("title", "Question Flagged for revision. Click to un-flag.");
|
||||
break;
|
||||
case 2:
|
||||
$nav_flag.removeClass().addClass('btn btn-success');
|
||||
$nav_flag.attr("title", "Question Answered. Click to flag for revision.");
|
||||
break;
|
||||
default:
|
||||
$nav_flag.removeClass().addClass('btn btn-secondary');
|
||||
$nav_flag.attr("title", "Question Un-Flagged. Click to flag for revision.");
|
||||
}
|
||||
}
|
||||
|
||||
function build_navigator() {
|
||||
$nav_container.html('')
|
||||
var output = ''
|
||||
for (let i = 0; i < questions.length; i ++) {
|
||||
let add_class, add_href, add_status = '';
|
||||
switch (question_status[i]) {
|
||||
case -1:
|
||||
add_class = 'btn-danger progress-bar-striped';
|
||||
add_href = 'href="#"';
|
||||
add_status = 'Incomplete';
|
||||
break;
|
||||
case 1:
|
||||
add_class = 'btn-warning';
|
||||
add_href = 'href="#"';
|
||||
add_status = 'Flagged';
|
||||
break;
|
||||
case 2:
|
||||
add_class = 'btn-success';
|
||||
add_href = 'href="#"';
|
||||
add_status = 'Answered';
|
||||
break;
|
||||
default:
|
||||
add_class = 'btn-secondary disabled';
|
||||
add_href = '';
|
||||
add_status = 'Unseen';
|
||||
}
|
||||
output += `<a ${add_href} class="q-navigator-button btn ${add_class}" name=${i} title="Question ${i+1}: ${add_status}">Q${i + 1}</a>`;
|
||||
}
|
||||
$nav_container.html(output);
|
||||
}
|
||||
|
||||
function update_navigator() {
|
||||
let button = $(`.q-navigator-button[name=${current_question}]`)
|
||||
if (current_question in question_status) {
|
||||
switch (question_status[current_question]) {
|
||||
case -1:
|
||||
button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped");
|
||||
button.attr("title", `Question ${current_question + 1}: Incomplete`);
|
||||
break;
|
||||
case 1:
|
||||
button.removeClass().addClass("q-navigator-button btn btn-warning");
|
||||
button.attr("title", `Question ${current_question + 1}: Flagged`);
|
||||
break;
|
||||
case 2:
|
||||
button.removeClass().addClass("q-navigator-button btn btn-success");
|
||||
button.attr("title", `Question ${current_question + 1}: Answered`);
|
||||
break;
|
||||
default:
|
||||
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled");
|
||||
button.attr("title", `Question ${current_question + 1}: Unseen`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
$("#btn-start-quiz").hide();
|
||||
$(".btn-quiz-return").show();
|
||||
$(".quiz-console").show();
|
||||
$("#quiz-settings").hide();
|
||||
$("#quiz-navigator").hide();
|
||||
$(".quiz-start-text").hide();
|
||||
|
||||
questions = JSON.parse(window.localStorage.getItem('questions'));
|
||||
total_questions = questions.length;
|
||||
start_time = window.localStorage.getItem('start_time');
|
||||
time_limit = window.localStorage.getItem('time_limit');
|
||||
|
||||
let get_answers = window.localStorage.getItem('answers');
|
||||
if (get_answers != null) {
|
||||
answers = JSON.parse(get_answers);
|
||||
}
|
||||
|
||||
let get_status = window.localStorage.getItem('question_status');
|
||||
if (get_status != null) {
|
||||
question_status = JSON.parse(get_status);
|
||||
}
|
||||
|
||||
render_question();
|
||||
build_navigator();
|
||||
check_flag();
|
||||
if (time_limit != 'null') {
|
||||
time_remaining = get_time_remaining();
|
||||
clock = setInterval(timer, 1000);
|
||||
} else {
|
||||
$("#q-timer-widget").hide();
|
||||
}
|
||||
}
|
||||
|
||||
function check_started() {
|
||||
let questions = window.localStorage.getItem('questions');
|
||||
let time_limit = window.localStorage.getItem('time_limit');
|
||||
let start_time = window.localStorage.getItem('start_time')
|
||||
if (questions != null && start_time != null && time_limit != null) {
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
function get_time_remaining() {
|
||||
var end_time = new Date(time_limit).getTime();
|
||||
var _start_time = new Date().getTime();
|
||||
return end_time - _start_time;
|
||||
}
|
||||
|
||||
function timer() {
|
||||
var hours = Math.floor((time_remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
var minutes = Math.floor((time_remaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
var seconds = Math.floor((time_remaining % (1000 * 60)) / 1000);
|
||||
|
||||
if (time_remaining > 0) {
|
||||
var timer_display = '';
|
||||
if (hours > 0) {
|
||||
timer_display = `${hours.toString()}: `;
|
||||
}
|
||||
if (minutes > 0 || hours > 0) {
|
||||
if (minutes < 10) {
|
||||
timer_display += `0${minutes.toString()}:`;
|
||||
} else {
|
||||
timer_display += `${minutes.toString()}:`;
|
||||
}
|
||||
}
|
||||
if (seconds < 10) {
|
||||
timer_display += `0${seconds.toString()}`;
|
||||
} else {
|
||||
timer_display += seconds.toString();
|
||||
}
|
||||
$timer.html(timer_display);
|
||||
time_remaining -= 1000
|
||||
} else {
|
||||
$timer.html('Expired');
|
||||
clearInterval(clock);
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
$quiz_render.hide();
|
||||
$quiz_navigator.hide();
|
||||
$quiz_timeout.show();
|
||||
$("#btn-toggle-navigator").addClass('disabled');
|
||||
$("#btn-toggle-settings").addClass('disabled')
|
||||
}
|
||||
|
||||
// Variable Definitions
|
||||
|
||||
const _id = window.localStorage.getItem('_id');
|
||||
@ -272,16 +555,21 @@ var current_question = 0;
|
||||
var total_questions = 0;
|
||||
var question_status = {};
|
||||
var answers = {};
|
||||
var questions = []
|
||||
var time_limit_data = ''
|
||||
var questions = [];
|
||||
var time_limit, start_time, time_remaining;
|
||||
|
||||
var display_settings = get_settings_from_storage();
|
||||
|
||||
const $quiz_settings = $("#quiz-settings");
|
||||
const $quiz_navigator = $("#quiz-navigator");
|
||||
const $quiz_render = $("#quiz-render");
|
||||
const $quiz_timeout = $("#quiz-timeout");
|
||||
const $nav_flag = $("#q-nav-flag");
|
||||
const $nav_next = $("#q-nav-next");
|
||||
const $nav_prev = $("#q-nav-prev");
|
||||
const $nav_container = $("#navigator-container");
|
||||
const $timer = $("#q-timer-display");
|
||||
var clock
|
||||
|
||||
var toggle_settings = false;
|
||||
var toggle_navigator = false;
|
||||
@ -294,9 +582,7 @@ const $question_options = $("#quiz-question-options");
|
||||
// Execution on Load
|
||||
|
||||
apply_settings(display_settings);
|
||||
check_started();
|
||||
|
||||
// TODO Build navigator
|
||||
// TODO Navigator Link button behaviour
|
||||
// TODO Resume Exam button
|
||||
// TODO Load state from storage
|
||||
// TODO Answer Registry
|
||||
// TODO Timeout Function
|
||||
// TODO Send data to server
|
||||
|
@ -55,3 +55,25 @@ $('form[name=form-quiz-start]').submit(function(event) {
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Dismiss Cookie Alert
|
||||
$('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'GET',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response);
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
})
|
@ -8,7 +8,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container quiz-panel" id="quiz-settings">
|
||||
<h1>Adjust Display Settings</h1>
|
||||
<div class="container quiz-start-text">
|
||||
@ -141,6 +140,10 @@
|
||||
<input type="radio" class="form-check-input" id="sample2" name="sample" value="2">
|
||||
<label for="sample2" class="form-check-label">The <i>Dungeons & Dragons Fifth Edition Monster Manual</i>.</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="radio" class="form-check-input" id="sample3" name="sample" value="3" checked>
|
||||
<label for="sample3" class="form-check-label">All of the above</i></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
@ -154,24 +157,72 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="container quiz-panel" style="display: none;" id="quiz-navigator">
|
||||
<h1>
|
||||
<h1 class="navigator-text">
|
||||
Navigator
|
||||
</h1>
|
||||
<div id="navigator-container">
|
||||
<a href="#" class="q-navigator-button btn btn-success">Q666</a>
|
||||
<a href="#" class="q-navigator-button btn btn-warning">Q666</a>
|
||||
<a href="#" class="q-navigator-button btn btn-secondary disabled">Q666</a>
|
||||
<h1 class="review-text" style="display: none;">
|
||||
Review Your Answers
|
||||
</h1>
|
||||
<div class="navigator-text">
|
||||
This is the exam navigator. It displays the progress you have on the exam so far. Each question is represented by an icon below, and you can click on each icon to skip to that question.
|
||||
|
||||
The icons below are colour-coded to represent the status of each question.
|
||||
</div>
|
||||
<div class="control-button-container">
|
||||
<div class="review-text" style="display: none;">
|
||||
You can use this panel to review your answers before you submit the exam. You will not be able to amend your answers after you submit.
|
||||
|
||||
Each question is represented by an icon below, and you can click on each icon to skip to that question. The icons below are colour-coded to represent the status of each question.
|
||||
</div>
|
||||
<table class="navigator-help">
|
||||
<tr>
|
||||
<td>
|
||||
<a class="q-navigator-button btn btn-danger progress-bar-striped btn-dummy" title="Question: Incomplete">Q</a>
|
||||
</td>
|
||||
<td>
|
||||
A red and striped icon represents a question that you have skipped, and have not otherwise flagged for revision.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a class="q-navigator-button btn btn-warning btn-dummy" title="Question: Flagged">Q</a>
|
||||
</td>
|
||||
<td>
|
||||
A yellow icon represents a question that you have flagged for revision.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a class="q-navigator-button btn btn-success btn-dummy" title="Question: Answered">Q</a>
|
||||
</td>
|
||||
<td>
|
||||
A green icon represents a question that you have answered, and have not otherwise flagged for revision.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a class="q-navigator-button btn btn-secondary disabled btn-dummy" title="Question: Unseen">Q</a>
|
||||
</td>
|
||||
<td>
|
||||
A greyed-out icon represents a question that you have not yet seen.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div id="navigator-container">
|
||||
</div>
|
||||
<div class="control-button-container navigator-text">
|
||||
<a href="#" class="btn btn-success btn-quiz-control btn-quiz-return">Resume Exam</a>
|
||||
</div>
|
||||
<div class="control-button-container review-text">
|
||||
<a href="#" class="btn btn-danger btn-quiz-control btn-quiz-return">Back to Exam</a>
|
||||
<a href="#" class="btn btn-success quiz-button-submit">Submit Exam</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container quiz-panel quiz-console" style="display: none" id="quiz-render">
|
||||
<h1>
|
||||
Exam Console
|
||||
</h1>
|
||||
<div class="container question-container">
|
||||
<h4 class="question-title" id="quiz-question-title">
|
||||
<h4 class="question-title" id="quiz-question-title" tabindex="-1">
|
||||
Question x.
|
||||
</h4>
|
||||
<p class="question-header" id="quiz-question-header">
|
||||
@ -186,10 +237,25 @@
|
||||
</div>
|
||||
<div class="control-button-container">
|
||||
<a href="#" class="btn btn-success q-question-nav" id="q-nav-prev" title="Previous Question"><i class="bi bi-caret-left-square-fill"></i> Back</a>
|
||||
<a href="#" class="btn btn-secondary" id="q-nav-flag" title="Flag Question"><i class="bi bi-flag-fill"></i></a>
|
||||
<a href="#" class="btn btn-secondary" id="q-nav-flag" title="Question Un-Flagged. Click to flag for revision."><i class="bi bi-flag-fill"></i></a>
|
||||
<a href="#" class="btn btn-success q-question-nav" id="q-nav-next" title="Next Question">Next <i class="bi bi-caret-right-square-fill"></i></a>
|
||||
</div>
|
||||
<div class="control-button-container">
|
||||
<a href="#" class="btn btn-primary" id="q-review-answers" title="Submit Answers">Submit Answers</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container quiz-panel quiz-timeout" style="display: none;" id="quiz-timeout">
|
||||
<h1>
|
||||
Time Limit Expired
|
||||
</h1>
|
||||
<p>
|
||||
The time limit set for this exam has expired. You must submit your answers immediately.
|
||||
</p>
|
||||
<div class="control-button-container">
|
||||
<a href="#" class="btn btn-success quiz-button-submit" title="Submit Exam">Submit Exam</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alert-box"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
|
||||
<div class="d-flex justify-content align-middle">
|
||||
<div class="container d-flex justify-content-center">
|
||||
<span class="text-light q-timer" id="q-timer-widget"><i class="bi bi-stopwatch-fill"></i> <span id="q-timer-display">1:58:57</span></span>
|
||||
<span class="text-light q-timer" id="q-timer-widget"><i class="bi bi-stopwatch-fill"></i> <span id="q-timer-display"></span></span>
|
||||
</div>
|
||||
<a href="#" class="btn btn-warning" aria-title="Navigate" title="Navigate" id="btn-toggle-navigator"><i class="bi bi-table"></i></a>
|
||||
<a href="#" class="btn btn-danger" aria-title="Settings" title="Settings" id="btn-toggle-settings"><i class="bi bi-gear-fill"></i></a>
|
||||
|
@ -0,0 +1,41 @@
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% set cookie_flash_flag = namespace(value=False) %}
|
||||
{% for category, message in messages %}
|
||||
{% if category == "error" %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "success" %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "warning" %}
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "cookie_alert" %}
|
||||
{% if not cookie_flash_flag.value %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
</div>
|
||||
{% set cookie_flash_flag.value = True %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
44
ref-test/quiz/templates/quiz/result.html
Normal file
44
ref-test/quiz/templates/quiz/result.html
Normal file
@ -0,0 +1,44 @@
|
||||
{% extends "quiz/components/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>SKA Refereeing Theory Exam</h1>
|
||||
|
||||
<h2>Candidate Results</h2>
|
||||
|
||||
<h3 class="results-name">
|
||||
<span class="surname">{{ entry.name.surname }}</span>, {{ entry.name.first_name }}
|
||||
</h3>
|
||||
|
||||
<strong class="results-details">Email Address</strong>: {{ entry.email }}
|
||||
|
||||
{% if entry.club %}
|
||||
<strong class="results-details">Club</strong>: {{ entry.club }}
|
||||
{% endif%}
|
||||
|
||||
{% if entry.status == 'late' %}
|
||||
|
||||
Your results are invalid because you did not submit your exam in time. Please contact the SKA Refereeing Coordinator.
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class="results-score">
|
||||
{{ score }}
|
||||
</div>
|
||||
|
||||
<div class="results-grade">
|
||||
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:] }}
|
||||
</div>
|
||||
|
||||
{% if entry.results.grade == 'fail' %}
|
||||
Unfortunately, you have not passed the theory exam in this attempt. For your next attempt, it might help to revise the following topics:
|
||||
|
||||
<ul>
|
||||
{% for tag in show_low_tags %}
|
||||
<li>{{ tag[0] }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
A copy of these results will be sent to you via email.
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -1,13 +1,16 @@
|
||||
from flask import Blueprint, render_template, request, redirect, jsonify, session, abort, flash
|
||||
from flask.helpers import url_for
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
import os
|
||||
from json import loads
|
||||
|
||||
from pymongo.collection import ReturnDocument
|
||||
|
||||
from main import app, db
|
||||
from common.security import encrypt
|
||||
from common.data_tools import generate_questions
|
||||
from common.data_tools import generate_questions, evaluate_answers
|
||||
from common.security.database import decrypt_find_one
|
||||
|
||||
views = Blueprint(
|
||||
'quiz_views',
|
||||
@ -20,6 +23,9 @@ views = Blueprint(
|
||||
@views.route('/')
|
||||
@views.route('/home/')
|
||||
def home():
|
||||
_id = session.get('_id')
|
||||
if _id and db.entries.find_one({'_id': _id}):
|
||||
return redirect(url_for('quiz_views.start_quiz'))
|
||||
return render_template('/quiz/index.html')
|
||||
|
||||
@views.route('/start/', methods = ['GET', 'POST'])
|
||||
@ -27,6 +33,9 @@ def start():
|
||||
from .forms import StartQuiz
|
||||
form = StartQuiz()
|
||||
if request.method == 'GET':
|
||||
_id = session.get('_id')
|
||||
if _id and db.entries.find_one({'_id': _id}):
|
||||
return redirect(url_for('quiz_views.start_quiz'))
|
||||
return render_template('/quiz/start-quiz.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
@ -47,12 +56,10 @@ def start():
|
||||
'email': encrypt(email),
|
||||
'club': encrypt(club),
|
||||
'test_code': test_code,
|
||||
'user_code': user_code,
|
||||
# 'start_time': datetime.utcnow(), TODO move start time to after configuration.
|
||||
# 'status': 'started'
|
||||
'user_code': user_code
|
||||
}
|
||||
if db.entries.insert(entry):
|
||||
session['_id'] = entry['_id'] # TODO Change this to return _id via JSON so client can access. Client will not be able to decrypt session cookie.
|
||||
session['_id'] = entry['_id']
|
||||
return jsonify({
|
||||
'success': 'Received and validated test and/or user code. Redirecting to test client.',
|
||||
'_id': entry['_id']
|
||||
@ -71,27 +78,79 @@ def fetch_questions():
|
||||
# user_code = entry['user_code'] TODO Implement functionality for adjustments
|
||||
|
||||
test = db.tests.find_one({'test_code' : test_code})
|
||||
time_limit = test['time_limit']
|
||||
if time_limit:
|
||||
_time_limit = int(time_limit)
|
||||
end_delta = timedelta(minutes=_time_limit)
|
||||
end_time = datetime.utcnow() + end_delta
|
||||
else:
|
||||
end_time = None
|
||||
update = {
|
||||
'start_time': datetime.utcnow(),
|
||||
'status': 'started',
|
||||
'end_time': end_time
|
||||
}
|
||||
entry = db.entries.find_one_and_update({'_id': _id}, {'$set': update}, upsert=False, return_document=ReturnDocument.AFTER)
|
||||
dataset = test['dataset']
|
||||
|
||||
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
|
||||
with open(dataset_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
|
||||
questions = generate_questions(data)
|
||||
time_limit = test['time_limit']
|
||||
return jsonify({
|
||||
'time_limit': time_limit,
|
||||
'questions': questions
|
||||
'time_limit': end_time,
|
||||
'questions': questions,
|
||||
'start_time': entry['start_time']
|
||||
})
|
||||
|
||||
@views.route('/test/')
|
||||
def start_quiz():
|
||||
_id = session.get('_id')
|
||||
if not _id or not db.entries.find_one({'_id': _id}):
|
||||
print('Foo')
|
||||
flash('Your log in was not recognised. Please sign in to the quiz again.', 'error')
|
||||
return redirect(url_for('quiz_views.start'))
|
||||
return render_template('quiz/client.html')
|
||||
|
||||
@views.route('/api/submit/', methods=['POST'])
|
||||
def submit_quiz():
|
||||
_id = request.get_json()['_id']
|
||||
answers = request.get_json()['answers']
|
||||
entry = db.entries.find_one({'_id': _id})
|
||||
if not entry:
|
||||
return jsonify('Unrecognised ID', 'error'), 400
|
||||
status = 'submitted'
|
||||
if entry['end_time']:
|
||||
if datetime.utcnow() > entry['end_time'] + timedelta(minutes=2):
|
||||
status = 'late'
|
||||
test_code = entry['test_code']
|
||||
test = db.tests.find_one({'test_code' : test_code})
|
||||
dataset = test['dataset']
|
||||
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
|
||||
with open(dataset_path, 'r') as _dataset:
|
||||
data = loads(_dataset.read())
|
||||
results = evaluate_answers(data, answers)
|
||||
entry = db.entries.find_one_and_update({'_id': _id}, {'$set': {
|
||||
'status': status,
|
||||
'submission_time': datetime.utcnow(),
|
||||
'results': results,
|
||||
'answers': answers
|
||||
}})
|
||||
return jsonify({
|
||||
'success': 'Your submission has been processed. Redirecting you to receive your results.',
|
||||
'_id': _id
|
||||
}), 200
|
||||
|
||||
@views.route('/result/')
|
||||
def result():
|
||||
_id = session.get('_id')
|
||||
entry = decrypt_find_one(db.entries, {'_id': _id})
|
||||
if not entry:
|
||||
return abort(404)
|
||||
score = round(100*entry['results']['score']/entry['results']['max'])
|
||||
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in entry['results']['tags'].items() }
|
||||
sorted_low = sorted(tags_low.items(), key=lambda x: x[1], reverse=True)
|
||||
show_low_tags = sorted_low[0:3]
|
||||
return render_template('/quiz/result.html', entry=entry, score=score, show_low_tags=show_low_tags)
|
||||
|
||||
@views.route('/privacy/')
|
||||
def privacy():
|
||||
|
Loading…
Reference in New Issue
Block a user