Finished client result API.

Need to work on adjustment user codes and server email notifications.
This commit is contained in:
Vivek Santayana 2021-11-30 18:06:24 +00:00
parent 61d2cc3a6d
commit 5dc64507a7
15 changed files with 710 additions and 54 deletions

View File

@ -47,14 +47,8 @@ class UpdateAccountForm(FlaskForm):
class CreateTest(FlaskForm): class CreateTest(FlaskForm):
start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() ) 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) ) 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') dataset = SelectField('Question Dataset')
class UploadDataForm(FlaskForm): class UploadDataForm(FlaskForm):

View File

@ -14,7 +14,7 @@ class Test:
self._id = _id self._id = _id
self.start_date = start_date self.start_date = start_date
self.expiry_date = expiry_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.creator = creator
self.dataset = dataset self.dataset = dataset
@ -88,7 +88,7 @@ class Test:
test['expiry_date'] = self.expiry_date test['expiry_date'] = self.expiry_date
updated.append('expiry date') updated.append('expiry date')
if not self.time_limit == '' and self.time_limit is not None: 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') updated.append('time limit')
output = '' output = ''
if len(updated) == 0: if len(updated) == 0:

View File

@ -16,7 +16,7 @@ import secrets
from main import mail from main import mail
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from .models.tests import Test 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( views = Blueprint(
'admin_views', 'admin_views',
@ -339,6 +339,7 @@ def tests(filter=''):
if filter == 'create': if filter == 'create':
from .models.forms import CreateTest from .models.forms import CreateTest
form = CreateTest() form = CreateTest()
form.time_limit.choices = get_time_options()
form.dataset.choices=available_datasets() form.dataset.choices=available_datasets()
form.time_limit.default='none' form.time_limit.default='none'
form.dataset.default=get_default_dataset() form.dataset.default=get_default_dataset()

View File

@ -1,7 +1,7 @@
import os import os
import pathlib import pathlib
from json import dump, loads from json import dump, loads
from datetime import datetime from datetime import datetime, timedelta
from flask.json import jsonify from flask.json import jsonify
from main import app from main import app
@ -100,3 +100,92 @@ def generate_questions(dataset:dict):
question['options'] = _question['options'].copy() question['options'] = _question['options'].copy()
output.append(question) output.append(question)
return output 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

View File

@ -37,11 +37,22 @@ def encrypt(input):
def decrypt(input): def decrypt(input):
if not check_keyfile_exists(): if not check_keyfile_exists():
raise EncryptionKeyMissing raise EncryptionKeyMissing
input = input.encode()
_encryption_key = load_key() _encryption_key = load_key()
fernet = Fernet(_encryption_key) fernet = Fernet(_encryption_key)
output = fernet.decrypt(input) if type(input) == str:
return output.decode() 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): class EncryptionKeyMissing(Exception):
def __init__(self, message='There is no encryption keyfile.'): def __init__(self, message='There is no encryption keyfile.'):

View File

@ -15,7 +15,7 @@ def decrypt_find(collection:collection, query:dict):
if not query: if not query:
output_list.append(decrypted_document) output_list.append(decrypted_document)
else: else:
if set(query.items()).issubset(set(decrypted_document.items())): if query.items() <= decrypted_document.items():
output_list.append(decrypted_document) output_list.append(decrypted_document)
return output_list return output_list

View File

@ -130,7 +130,20 @@
.control-button-container { .control-button-container {
width: fit-content; 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 */ /* Layout for Mobile Devices */

View File

@ -4,6 +4,11 @@ body {
font-size: 14pt; font-size: 14pt;
} }
#dismiss-cookie-alert {
margin-top: 16px;
width: 100%;
}
.site-footer { .site-footer {
background-color: lightgray; background-color: lightgray;
font-size: small; font-size: small;
@ -133,6 +138,31 @@ body {
margin: 0 2px; 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*/ /* Change Autocomplete styles in Chrome*/
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,

View File

@ -1,4 +1,4 @@
// Click Listeners // Bind Listeners
$("input[name='font-select']").change(function(){ $("input[name='font-select']").change(function(){
let $choice = $(this).val(); let $choice = $(this).val();
@ -16,6 +16,8 @@ $("input[name='bg-select']").change(function(){
}); });
$("#btn-toggle-navigator").click(function(event){ $("#btn-toggle-navigator").click(function(event){
check_answered();
update_navigator();
if ($quiz_navigator.is(":hidden")) { if ($quiz_navigator.is(":hidden")) {
if ($quiz_settings.is(":visible")) { if ($quiz_settings.is(":visible")) {
toggle_settings = true; toggle_settings = true;
@ -23,6 +25,8 @@ $("#btn-toggle-navigator").click(function(event){
} }
$quiz_render.hide(); $quiz_render.hide();
$quiz_navigator.show(); $quiz_navigator.show();
$(".navigator-text").show();
$(".review-text").hide();
toggle_navigator = false; toggle_navigator = false;
} else { } else {
$quiz_navigator.hide(); $quiz_navigator.hide();
@ -57,12 +61,35 @@ $("#btn-toggle-settings").click(function(event){
event.preventDefault(); 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){ $(".btn-dummy").click(function(event){
event.preventDefault(); 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){ $(".q-question-nav").click(function(event){
check_answered(); check_answered();
update_navigator();
if ($(this).attr("id") == "q-nav-next") { if ($(this).attr("id") == "q-nav-next") {
if (current_question < questions.length) { if (current_question < questions.length) {
current_question ++; current_question ++;
@ -71,6 +98,12 @@ $(".q-question-nav").click(function(event){
if (current_question > 0) { if (current_question > 0) {
current_question --; 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(); render_question();
check_flag(); check_flag();
@ -81,10 +114,14 @@ $("#q-nav-flag").click(function(event){
if (question_status[current_question] != 1) { if (question_status[current_question] != 1) {
question_status[current_question] = 1; question_status[current_question] = 1;
$(this).removeClass().addClass("btn btn-warning"); $(this).removeClass().addClass("btn btn-warning");
$(this).attr("title", "Question Flagged for revision. Click to un-flag.");
} else { } else {
question_status[current_question] = 0; question_status[current_question] = 0;
$(this).removeClass().addClass("btn btn-secondary"); $(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(); event.preventDefault();
}); });
@ -102,12 +139,22 @@ $("#btn-start-quiz").click(function(event){
data: JSON.stringify({'_id': _id}), data: JSON.stringify({'_id': _id}),
contentType: "application/json", contentType: "application/json",
success: function(response) { success: function(response) {
time_limit_data = response.time_limit; time_limit = response.time_limit;
start_time = response.start_time;
questions = response.questions; questions = response.questions;
total_questions = questions.length; total_questions = questions.length;
window.localStorage.setItem('questions', JSON.stringify(questions)); 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(); render_question();
build_navigator();
check_flag(); check_flag();
if (time_limit != 'null') {
time_remaining = get_time_remaining();
clock = setInterval(timer, 1000);
} else {
$("#q-timer-widget").hide();
}
}, },
error: function(response) { error: function(response) {
console.log(response); console.log(response);
@ -117,6 +164,79 @@ $("#btn-start-quiz").click(function(event){
event.preventDefault(); 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 // Functions
function set_font(value = 'osdefault') { function set_font(value = 'osdefault') {
@ -229,41 +349,204 @@ function render_question() {
$question_text.html(question.text); $question_text.html(question.text);
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`); $question_title.html(`Question ${current_question + 1} of ${ questions.length }.`);
var q_no = question['q_no'];
var options = question.options; var options = question.options;
var options_output = ''; var options_output = '';
for (let i = 0; i < options.length; i ++) { 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"> 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> <label for="q${current_question}-${i}" class="form-check-label">${options[i]}</label>
</div>`; </div>`;
} }
$question_options.html(options_output); $question_options.html(options_output);
$question_title.focus();
} }
function check_answered() { function check_answered() {
var question = questions[current_question]; var question = questions[current_question];
var name = question.q_no; var name = question.q_no;
if (!$(`input[name='${name}']:checked`).val() && question_status[current_question] == 0) { if (question_status[current_question] == 0) {
question_status[current_question] = -1; 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() { function check_flag() {
if (!(current_question in question_status)) { if (!(current_question in question_status)) {
question_status[current_question] = 0; question_status[current_question] = 0;
window.localStorage.setItem('question_status', JSON.stringify(question_status));
} }
switch (question_status[current_question]) { switch (question_status[current_question]) {
case -1: 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; break;
case 1: case 1:
$nav_flag.removeClass().addClass('btn btn-warning'); $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; break;
default: default:
$nav_flag.removeClass().addClass('btn btn-secondary'); $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 // Variable Definitions
const _id = window.localStorage.getItem('_id'); const _id = window.localStorage.getItem('_id');
@ -272,16 +555,21 @@ var current_question = 0;
var total_questions = 0; var total_questions = 0;
var question_status = {}; var question_status = {};
var answers = {}; var answers = {};
var questions = [] var questions = [];
var time_limit_data = '' var time_limit, start_time, time_remaining;
var display_settings = get_settings_from_storage(); var display_settings = get_settings_from_storage();
const $quiz_settings = $("#quiz-settings"); const $quiz_settings = $("#quiz-settings");
const $quiz_navigator = $("#quiz-navigator"); const $quiz_navigator = $("#quiz-navigator");
const $quiz_render = $("#quiz-render"); const $quiz_render = $("#quiz-render");
const $quiz_timeout = $("#quiz-timeout");
const $nav_flag = $("#q-nav-flag"); const $nav_flag = $("#q-nav-flag");
const $nav_next = $("#q-nav-next"); const $nav_next = $("#q-nav-next");
const $nav_prev = $("#q-nav-prev"); const $nav_prev = $("#q-nav-prev");
const $nav_container = $("#navigator-container");
const $timer = $("#q-timer-display");
var clock
var toggle_settings = false; var toggle_settings = false;
var toggle_navigator = false; var toggle_navigator = false;
@ -294,9 +582,7 @@ const $question_options = $("#quiz-question-options");
// Execution on Load // Execution on Load
apply_settings(display_settings); apply_settings(display_settings);
check_started();
// TODO Build navigator // TODO Timeout Function
// TODO Navigator Link button behaviour // TODO Send data to server
// TODO Resume Exam button
// TODO Load state from storage
// TODO Answer Registry

View File

@ -55,3 +55,25 @@ $('form[name=form-quiz-start]').submit(function(event) {
event.preventDefault(); 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();
})

View File

@ -8,7 +8,6 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container quiz-panel" id="quiz-settings"> <div class="container quiz-panel" id="quiz-settings">
<h1>Adjust Display Settings</h1> <h1>Adjust Display Settings</h1>
<div class="container quiz-start-text"> <div class="container quiz-start-text">
@ -141,6 +140,10 @@
<input type="radio" class="form-check-input" id="sample2" name="sample" value="2"> <input type="radio" class="form-check-input" id="sample2" name="sample" value="2">
<label for="sample2" class="form-check-label">The <i>Dungeons &amp; Dragons Fifth Edition Monster Manual</i>.</label> <label for="sample2" class="form-check-label">The <i>Dungeons &amp; Dragons Fifth Edition Monster Manual</i>.</label>
</div> </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> </div>
<div class="row mt-3"> <div class="row mt-3">
@ -154,24 +157,72 @@
</div> </div>
</div> </div>
<div class="container quiz-panel" style="display: none;" id="quiz-navigator"> <div class="container quiz-panel" style="display: none;" id="quiz-navigator">
<h1> <h1 class="navigator-text">
Navigator Navigator
</h1> </h1>
<div id="navigator-container"> <h1 class="review-text" style="display: none;">
<a href="#" class="q-navigator-button btn btn-success">Q666</a> Review Your Answers
<a href="#" class="q-navigator-button btn btn-warning">Q666</a> </h1>
<a href="#" class="q-navigator-button btn btn-secondary disabled">Q666</a> <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>
<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> <a href="#" class="btn btn-success btn-quiz-control btn-quiz-return">Resume Exam</a>
</div> </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>
<div class="container quiz-panel quiz-console" style="display: none" id="quiz-render"> <div class="container quiz-panel quiz-console" style="display: none" id="quiz-render">
<h1> <h1>
Exam Console Exam Console
</h1> </h1>
<div class="container question-container"> <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. Question x.
</h4> </h4>
<p class="question-header" id="quiz-question-header"> <p class="question-header" id="quiz-question-header">
@ -186,10 +237,25 @@
</div> </div>
<div class="control-button-container"> <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>&nbsp;Back</a> <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>&nbsp;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&nbsp;<i class="bi bi-caret-right-square-fill"></i></a> <a href="#" class="btn btn-success q-question-nav" id="q-nav-next" title="Next Question">Next&nbsp;<i class="bi bi-caret-right-square-fill"></i></a>
</div> </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>
<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 %} {% endblock %}
{% block script %} {% block script %}
<script <script

View File

@ -4,7 +4,7 @@
<div class="quiz-console w-100" style="display: none;" id="q-topbar"> <div class="quiz-console w-100" style="display: none;" id="q-topbar">
<div class="d-flex justify-content align-middle"> <div class="d-flex justify-content align-middle">
<div class="container d-flex justify-content-center"> <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>&nbsp;<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>&nbsp;<span id="q-timer-display"></span></span>
</div> </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-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> <a href="#" class="btn btn-danger" aria-title="Settings" title="Settings" id="btn-toggle-settings"><i class="bi bi-gear-fill"></i></a>

View File

@ -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 %}

View 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 %}

View File

@ -1,13 +1,16 @@
from flask import Blueprint, render_template, request, redirect, jsonify, session, abort, flash from flask import Blueprint, render_template, request, redirect, jsonify, session, abort, flash
from flask.helpers import url_for from flask.helpers import url_for
from datetime import datetime from datetime import datetime, timedelta
from uuid import uuid4 from uuid import uuid4
import os import os
from json import loads from json import loads
from pymongo.collection import ReturnDocument
from main import app, db from main import app, db
from common.security import encrypt 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( views = Blueprint(
'quiz_views', 'quiz_views',
@ -20,6 +23,9 @@ views = Blueprint(
@views.route('/') @views.route('/')
@views.route('/home/') @views.route('/home/')
def 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') return render_template('/quiz/index.html')
@views.route('/start/', methods = ['GET', 'POST']) @views.route('/start/', methods = ['GET', 'POST'])
@ -27,6 +33,9 @@ def start():
from .forms import StartQuiz from .forms import StartQuiz
form = StartQuiz() form = StartQuiz()
if request.method == 'GET': 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) return render_template('/quiz/start-quiz.html', form=form)
if request.method == 'POST': if request.method == 'POST':
if form.validate_on_submit(): if form.validate_on_submit():
@ -47,12 +56,10 @@ def start():
'email': encrypt(email), 'email': encrypt(email),
'club': encrypt(club), 'club': encrypt(club),
'test_code': test_code, 'test_code': test_code,
'user_code': user_code, 'user_code': user_code
# 'start_time': datetime.utcnow(), TODO move start time to after configuration.
# 'status': 'started'
} }
if db.entries.insert(entry): 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({ return jsonify({
'success': 'Received and validated test and/or user code. Redirecting to test client.', 'success': 'Received and validated test and/or user code. Redirecting to test client.',
'_id': entry['_id'] '_id': entry['_id']
@ -71,27 +78,79 @@ def fetch_questions():
# user_code = entry['user_code'] TODO Implement functionality for adjustments # user_code = entry['user_code'] TODO Implement functionality for adjustments
test = db.tests.find_one({'test_code' : test_code}) 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 = test['dataset']
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset) dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
with open(dataset_path, 'r') as data_file: with open(dataset_path, 'r') as data_file:
data = loads(data_file.read()) data = loads(data_file.read())
questions = generate_questions(data) questions = generate_questions(data)
time_limit = test['time_limit']
return jsonify({ return jsonify({
'time_limit': time_limit, 'time_limit': end_time,
'questions': questions 'questions': questions,
'start_time': entry['start_time']
}) })
@views.route('/test/') @views.route('/test/')
def start_quiz(): def start_quiz():
_id = session.get('_id') _id = session.get('_id')
if not _id or not db.entries.find_one({'_id': _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') flash('Your log in was not recognised. Please sign in to the quiz again.', 'error')
return redirect(url_for('quiz_views.start')) return redirect(url_for('quiz_views.start'))
return render_template('quiz/client.html') 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/') @views.route('/privacy/')
def privacy(): def privacy():