From ac81dc20996ec4c489e0908d08cf5b969855bbef Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Tue, 30 Nov 2021 18:06:24 +0000 Subject: [PATCH] Finished client result API. Need to work on adjustment user codes and server email notifications. --- ref-test/admin/models/forms.py | 8 +- ref-test/admin/models/tests.py | 4 +- ref-test/admin/views.py | 3 +- ref-test/common/data_tools.py | 93 +++++- ref-test/common/security/__init__.py | 17 +- ref-test/common/security/database.py | 2 +- ref-test/quiz/static/css/quiz.css | 15 +- ref-test/quiz/static/css/style.css | 30 ++ ref-test/quiz/static/js/quiz.js | 314 +++++++++++++++++- ref-test/quiz/static/js/script.js | 24 +- ref-test/quiz/templates/quiz/client.html | 84 ++++- .../templates/quiz/components/navbar.html | 2 +- .../quiz/components/server-alerts.html | 41 +++ ref-test/quiz/templates/quiz/result.html | 44 +++ ref-test/quiz/views.py | 83 ++++- 15 files changed, 710 insertions(+), 54 deletions(-) create mode 100644 ref-test/quiz/templates/quiz/result.html diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index ee05b74..9ca6e1e 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -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): diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index a0e44cd..2ba8573 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -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: diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 5ff95cf..6159f7d 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -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() diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py index 1497b51..fdf2d71 100644 --- a/ref-test/common/data_tools.py +++ b/ref-test/common/data_tools.py @@ -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 @@ -99,4 +99,93 @@ def generate_questions(dataset:dict): else: question['options'] = _question['options'].copy() output.append(question) - return output \ No newline at end of file + 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 \ No newline at end of file diff --git a/ref-test/common/security/__init__.py b/ref-test/common/security/__init__.py index 0272741..e553347 100644 --- a/ref-test/common/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -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) - output = fernet.decrypt(input) - return output.decode() + 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.'): diff --git a/ref-test/common/security/database.py b/ref-test/common/security/database.py index 9adb51e..74a41c3 100644 --- a/ref-test/common/security/database.py +++ b/ref-test/common/security/database.py @@ -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 diff --git a/ref-test/quiz/static/css/quiz.css b/ref-test/quiz/static/css/quiz.css index e416f9b..ef96a56 100644 --- a/ref-test/quiz/static/css/quiz.css +++ b/ref-test/quiz/static/css/quiz.css @@ -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 */ diff --git a/ref-test/quiz/static/css/style.css b/ref-test/quiz/static/css/style.css index 9cfdbb6..b955c42 100644 --- a/ref-test/quiz/static/css/style.css +++ b/ref-test/quiz/static/css/style.css @@ -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, diff --git a/ref-test/quiz/static/js/quiz.js b/ref-test/quiz/static/js/quiz.js index 73070c9..b71ed0e 100644 --- a/ref-test/quiz/static/js/quiz.js +++ b/ref-test/quiz/static/js/quiz.js @@ -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 + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + 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 += `
- +
`; } $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) { - question_status[current_question] = -1; + 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.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 += `Q${i + 1}`; + } + $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 \ No newline at end of file +// TODO Timeout Function +// TODO Send data to server diff --git a/ref-test/quiz/static/js/script.js b/ref-test/quiz/static/js/script.js index 2cbce8a..691ecfc 100644 --- a/ref-test/quiz/static/js/script.js +++ b/ref-test/quiz/static/js/script.js @@ -54,4 +54,26 @@ $('form[name=form-quiz-start]').submit(function(event) { }); event.preventDefault(); -}); \ No newline at end of file +}); + +// 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(); + +}) \ No newline at end of file diff --git a/ref-test/quiz/templates/quiz/client.html b/ref-test/quiz/templates/quiz/client.html index 36b78b6..46fa1f1 100644 --- a/ref-test/quiz/templates/quiz/client.html +++ b/ref-test/quiz/templates/quiz/client.html @@ -8,7 +8,6 @@ {% endblock %} {% block content %} -

Adjust Display Settings

@@ -141,6 +140,10 @@
+
+ + +
@@ -154,24 +157,72 @@