diff --git a/nginx/conf.d/ref-test-app.conf b/nginx/conf.d/ref-test-app.conf index 36c1247..24c2694 100644 --- a/nginx/conf.d/ref-test-app.conf +++ b/nginx/conf.d/ref-test-app.conf @@ -19,6 +19,7 @@ server { include /etc/nginx/ssl.conf; include /etc/nginx/certbot-challenge.conf; + # Define locations for static files to be served by Nginx location ^~ /quiz/static/ { include /etc/nginx/mime.types; alias /usr/share/nginx/html/quiz/static/; @@ -34,6 +35,12 @@ server { alias /usr/share/nginx/html/admin/editor/static/; } + location ^~ /admin/view/static/ { + include /etc/nginx/mime.types; + alias /usr/share/nginx/html/admin/view/static/; + } + + # Proxy to the main app for all other requests location / { include /etc/nginx/conf.d/proxy_headers.conf; proxy_pass http://reftest; diff --git a/ref-test/app/__init__.py b/ref-test/app/__init__.py index 4122f2c..66dfc25 100644 --- a/ref-test/app/__init__.py +++ b/ref-test/app/__init__.py @@ -46,11 +46,13 @@ def create_app(): from .quiz.views import quiz from .views import views from .editor.views import editor + from .view.views import view app.register_blueprint(admin, url_prefix='/admin') app.register_blueprint(api, url_prefix='/api') app.register_blueprint(views) app.register_blueprint(quiz) app.register_blueprint(editor, url_prefix='/admin/editor') + app.register_blueprint(view, url_prefix='/admin/view') return app \ No newline at end of file diff --git a/ref-test/app/admin/templates/admin/components/navbar.html b/ref-test/app/admin/templates/admin/components/navbar.html index 48686d4..4dbe334 100644 --- a/ref-test/app/admin/templates/admin/components/navbar.html +++ b/ref-test/app/admin/templates/admin/components/navbar.html @@ -77,10 +77,13 @@ Users
  • - Question Datasets + Manage Questions
  • - Question Editor + View Questions +
  • +
  • + Edit Questions
  • diff --git a/ref-test/app/admin/templates/admin/settings/questions.html b/ref-test/app/admin/templates/admin/settings/questions.html index eed4611..d9e941a 100644 --- a/ref-test/app/admin/templates/admin/settings/questions.html +++ b/ref-test/app/admin/templates/admin/settings/questions.html @@ -57,28 +57,37 @@ class="btn btn-primary edit-question-dataset" data-id="{{ element.id }}" data-action="download" - title="Download Dataset" + title="Download Questions" > - + + + + - - + + - + {% endfor %} diff --git a/ref-test/app/editor/static/css/editor.css b/ref-test/app/editor/static/css/editor.css index 84544fd..133e762 100644 --- a/ref-test/app/editor/static/css/editor.css +++ b/ref-test/app/editor/static/css/editor.css @@ -71,7 +71,7 @@ margin: 30pt auto; } -.info-panel { +.info-panel, .viewer-panel { display: none; } @@ -84,4 +84,20 @@ #alert-box { margin: 30px auto; max-width: 460px; +} + +.block { + border: 2px solid black; + border-radius: 10px; + margin: 10px; + padding: 5px; +} + +.question-body, .question-block { + padding: 0px 2em; +} + +blockquote { + padding: 0px 2em; + font-style: italic; } \ No newline at end of file diff --git a/ref-test/app/editor/static/js/editor.js b/ref-test/app/editor/static/js/editor.js index 85e9fd1..f7eb64e 100644 --- a/ref-test/app/editor/static/js/editor.js +++ b/ref-test/app/editor/static/js/editor.js @@ -5,24 +5,69 @@ const id = $root.data('id') const $control_panel = $('.control-panel') const $info_panel = $('.info-panel') +const $viewer_panel = $('.viewer-panel') const $editor_panel = $('.editor-panel') +var toggle_info = false +var toggle_viewer = false + var element_index = 0 // Initialise Sortable and trigger renumbering on end of drag Sortable.create($root.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}}) -// Info Button Listener +// Info and Viewer Button Listener $control_panel.find('button').click(function(event){ - if ($info_panel.is(":hidden")) { - $editor_panel.hide() - $info_panel.fadeIn() - $(this).addClass('active') - } else { - $info_panel.hide() - $editor_panel.fadeIn() - $(this).removeClass('active') + var action = $(this).data('action'); + + if (action == 'info') { + if ($info_panel.is(":hidden")) { + if ($viewer_panel.is(":visible")) { + toggle_viewer = true + $viewer_panel.hide() + } + $editor_panel.hide() + $info_panel.fadeIn() + $(window).scrollTop(0) + toggle_info = false + $(this).addClass('active') + } else { + $info_panel.hide() + if (toggle_viewer) { + render_viewer() + $(window).scrollTop(0) + toggle_viewer = false + } else { + $editor_panel.fadeIn() + $(window).scrollTop(0) + } + $(this).removeClass('active') + } + } else if (action == 'view') { + if ($viewer_panel.is(":hidden")) { + if ($info_panel.is(':visible')) { + toggle_info = true + $info_panel.hide() + } + $editor_panel.hide() + render_viewer() + $(window).scrollTop(0) + toggle_viewer = false + $(this).addClass('active') + } else { + $viewer_panel.hide() + if (toggle_info) { + $info_panel.fadeIn() + $(window).scrollTop(0) + toggle_info = false + } else { + $editor_panel.fadeIn() + $(window).scrollTop(0) + } + $(this).removeClass('active') + } } + event.preventDefault() }) @@ -496,4 +541,102 @@ $(window).on('load', function() { console.log(response) } }) -}) \ No newline at end of file +}) + +// Viewer Render Function +function render_viewer() { + $viewer_panel.fadeIn() + $viewer_panel.empty() + var heading = document.createElement('h3') + heading.innerText = 'View Questions' + $viewer_panel.append(heading) + var data = parse_input() + var block + var obj + for (let i = 0; i < data.length; i++) { + block = data[i] + obj = document.createElement('div') + obj.classList = 'block' + if (block['type'] == 'question') { + text = document.createElement('p') + text.innerHTML = `Question ${block['q_no'] + 1}. ${block['text']}` + obj.append(text) + question_body = document.createElement('div') + question_body.className ='question-body' + type = document.createElement('p') + type.innerHTML = `Question Type: ${block['q_type']}` + question_body.append(type) + options = document.createElement('p') + options.innerHTML = 'Options:' + option_list = document.createElement('ul') + for (let _i = 0; _i < block['options'].length; _i++) { + option = document.createElement('li') + option.innerHTML = block['options'][_i] + if (block['correct'] == _i) { + option.innerHTML += ' Correct' + } + option_list.append(option) + } + options.append(option_list) + question_body.append(options) + tags = document.createElement('p') + tags.innerHTML = `Tags:` + tag_list = document.createElement('ul') + for (let _i = 0; _i < block['tags'].length; _i++) { + tag = document.createElement('li') + tag.innerHTML = block['tags'][_i] + tag_list.append(tag) + } + tags.append(tag_list) + question_body.append(tags) + obj.append(question_body) + } else if (block['type'] == 'block') { + meta = document.createElement('p') + meta.innerHTML = `Block ${i+1}. ${block['questions'].length} questions.` + obj.append(meta) + header = document.createElement('blockquote') + header.innerText = block['question_header'] + obj.append(header) + var block_question = document.createElement('div') + var question + block_question.className = 'question-block' + for (let _i = 0; _i < block['questions'].length; _i++) { + question = block['questions'][_i] + text = document.createElement('p') + text.innerHTML = `Question ${question['q_no'] + 1}. ${question['text']}` + block_question.append(text) + question_body = document.createElement('div') + question_body.className ='question-body' + type = document.createElement('p') + type.innerHTML = `Question Type: ${question['q_type']}` + question_body.append(type) + options = document.createElement('p') + options.innerHTML = 'Options:' + option_list = document.createElement('ul') + for (let __i = 0; __i < question['options'].length; __i++) { + option = document.createElement('li') + option.innerHTML = question['options'][__i] + if (question['correct'] == __i) { + option.innerHTML += ' Correct' + } + option_list.append(option) + } + options.append(option_list) + question_body.append(options) + tags = document.createElement('p') + tags.innerHTML = `Tags:` + tag_list = document.createElement('ul') + for (let __i = 0; __i < question['tags'].length; __i++) { + tag = document.createElement('li') + tag.innerHTML = question['tags'][__i] + tag_list.append(tag) + } + tags.append(tag_list) + question_body.append(tags) + block_question.append(question_body) + obj.append(block_question) + } + } + $viewer_panel.append(obj) + } +} \ No newline at end of file diff --git a/ref-test/app/editor/templates/editor/components/navbar.html b/ref-test/app/editor/templates/editor/components/navbar.html index 48686d4..4dbe334 100644 --- a/ref-test/app/editor/templates/editor/components/navbar.html +++ b/ref-test/app/editor/templates/editor/components/navbar.html @@ -77,10 +77,13 @@ Users
  • - Question Datasets + Manage Questions
  • - Question Editor + View Questions +
  • +
  • + Edit Questions
  • diff --git a/ref-test/app/editor/templates/editor/console.html b/ref-test/app/editor/templates/editor/console.html index b532cf5..e244e73 100644 --- a/ref-test/app/editor/templates/editor/console.html +++ b/ref-test/app/editor/templates/editor/console.html @@ -11,11 +11,12 @@

    Editor

    - Use this console to edit the questions in this dataset. For more information on using the editor console, click on the the blue information button. + Use this console to edit the questions in this dataset. For more information on using the editor console, click on the the blue Information button. To preview the questions in the current dataset, click on the green View Questions button.

    - + +

    @@ -77,9 +78,11 @@ In order to show how many questions are remaining inside a block, e.g. to say ‘the next n questions are about a specific scenario’, use the placeholder <block_remaining_questions>.

    +
    +

    - Edit Dataset + Edit Questions

    @@ -98,7 +101,7 @@ Last Updated {{ dataset.date.strftime('%d %b %Y %H:%M') }} - +
    diff --git a/ref-test/app/view/__init__.py b/ref-test/app/view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ref-test/app/view/static/css/style.css b/ref-test/app/view/static/css/style.css new file mode 100644 index 0000000..7a22f31 --- /dev/null +++ b/ref-test/app/view/static/css/style.css @@ -0,0 +1,260 @@ +body { + padding: 80px 0; +} + +.site-footer { + background-color: lightgray; + font-size: small; +} + +.site-footer p { + margin: 0; +} + +.form-container { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding-top: 40px; + padding-bottom: 40px; +} + +.form-display { + width: 100%; + max-width: 420px; + padding: 15px; + margin: auto; +} + +.form-heading { + margin-bottom: 2rem; +} + +.form-label-group { + position: relative; + margin-bottom: 2rem; +} + +.form-label-group input, +.form-label-group label { + padding: var(--input-padding-y) var(--input-padding-x); + font-size: 16pt; +} + +.form-label-group label { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + margin-bottom: 0; /* Override default `
    + `) + } else if (response.responseJSON.error instanceof Array) { + var output = '' + for (let i = 0; i < response.responseJSON.error.length; i ++) { + output += ` + + ` + $alert.html(output) + } + } + + $alert.focus() +} + +// Dismiss Cookie Alert +$('#dismiss-cookie-alert').click(function(event){ + + $.ajax({ + url: '/cookies/', + type: 'POST', + data: { + time: Date.now() + }, + dataType: 'json', + success: function(response){ + console.log(response) + }, + error: function(response){ + console.log(response) + } + }) + + event.preventDefault() +}) + +// Create New Dataset +$('.create-new-dataset').click(function(event){ + $.ajax({ + url: '/api/editor/new/', + type: 'POST', + data: { + time: Date.now() + }, + dataType: 'json', + success: function(response){ + if (response.redirect_to) { + window.location.href = response.redirect_to + } + }, + error: function(response){ + console.log(response) + } + }) + event.preventDefault() +}) \ No newline at end of file diff --git a/ref-test/app/view/static/js/view.js b/ref-test/app/view/static/js/view.js new file mode 100644 index 0000000..0d78304 --- /dev/null +++ b/ref-test/app/view/static/js/view.js @@ -0,0 +1,130 @@ +// Variable Declarations +const $control_panel = $('.control-panel') +const $info_panel = $('.info-panel') +const $viewer_panel = $('.viewer-panel') + +var element_index = 0 + +// Info Button Listener +$control_panel.find('button').click(function(event){ + if ($info_panel.is(":hidden")) { + $viewer_panel.hide() + $info_panel.fadeIn() + $(this).addClass('active') + } else { + $info_panel.hide() + $viewer_panel.fadeIn() + $(this).removeClass('active') + } + event.preventDefault() +}) + +function parse_data(data) { + var block + var obj + for (let i = 0; i < data.length; i++) { + block = data[i] + obj = document.createElement('div') + obj.classList = 'block' + if (block['type'] == 'question') { + text = document.createElement('p') + text.innerHTML = `Question ${block['q_no'] + 1}. ${block['text']}` + obj.append(text) + question_body = document.createElement('div') + question_body.className ='question-body' + type = document.createElement('p') + type.innerHTML = `Question Type: ${block['q_type']}` + question_body.append(type) + options = document.createElement('p') + options.innerHTML = 'Options:' + option_list = document.createElement('ul') + for (let _i = 0; _i < block['options'].length; _i++) { + option = document.createElement('li') + option.innerHTML = block['options'][_i] + if (block['correct'] == _i) { + option.innerHTML += ' Correct' + } + option_list.append(option) + } + options.append(option_list) + question_body.append(options) + tags = document.createElement('p') + tags.innerHTML = `Tags:` + tag_list = document.createElement('ul') + for (let _i = 0; _i < block['tags'].length; _i++) { + tag = document.createElement('li') + tag.innerHTML = block['tags'][_i] + tag_list.append(tag) + } + tags.append(tag_list) + question_body.append(tags) + obj.append(question_body) + } else if (block['type'] == 'block') { + meta = document.createElement('p') + meta.innerHTML = `Block ${i+1}. ${block['questions'].length} questions.` + obj.append(meta) + header = document.createElement('blockquote') + header.innerText = block['question_header'] + obj.append(header) + var block_question = document.createElement('div') + var question + block_question.className = 'question-block' + for (let _i = 0; _i < block['questions'].length; _i++) { + question = block['questions'][_i] + text = document.createElement('p') + text.innerHTML = `Question ${question['q_no'] + 1}. ${question['text']}` + block_question.append(text) + question_body = document.createElement('div') + question_body.className ='question-body' + type = document.createElement('p') + type.innerHTML = `Question Type: ${question['q_type']}` + question_body.append(type) + options = document.createElement('p') + options.innerHTML = 'Options:' + option_list = document.createElement('ul') + for (let __i = 0; __i < question['options'].length; __i++) { + option = document.createElement('li') + option.innerHTML = question['options'][__i] + if (question['correct'] == __i) { + option.innerHTML += ' Correct' + } + option_list.append(option) + } + options.append(option_list) + question_body.append(options) + tags = document.createElement('p') + tags.innerHTML = `Tags:` + tag_list = document.createElement('ul') + for (let __i = 0; __i < question['tags'].length; __i++) { + tag = document.createElement('li') + tag.innerHTML = question['tags'][__i] + tag_list.append(tag) + } + tags.append(tag_list) + question_body.append(tags) + block_question.append(question_body) + obj.append(block_question) + } + } + $viewer_panel.append(obj) + } +} + +// Fetch data once page finishes loading +$(window).on('load', function() { + $.ajax({ + url: target, + type: 'POST', + data: JSON.stringify({ + 'id': id, + 'action': 'fetch' + }), + contentType: 'application/json', + success: function(response) { + parse_data(response['data']) + }, + error: function(response) { + console.log(response) + } + }) +}) \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/base.html b/ref-test/app/view/templates/view/components/base.html new file mode 100644 index 0000000..887b851 --- /dev/null +++ b/ref-test/app/view/templates/view/components/base.html @@ -0,0 +1,84 @@ + + + + + + + + + + {% block style %} + {% endblock %} + {% block title %} SKA Referee Test | Admin Console {% endblock %} + {% include "view/components/og-meta.html" %} + + + + {% block navbar %} + {% include "view/components/navbar.html" %} + {% endblock %} + +
    + {% block top_alerts %} + {% include "view/components/server-alerts.html" %} + {% endblock %} + {% block content %}{% endblock %} +
    + +
    + {% block footer %} + {% include "view/components/footer.html" %} + {% endblock %} +
    + + + + + + + + + + {% block script %} + {% endblock %} + + \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/client-alerts.html b/ref-test/app/view/templates/view/components/client-alerts.html new file mode 100644 index 0000000..48a43e2 --- /dev/null +++ b/ref-test/app/view/templates/view/components/client-alerts.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/datatable.html b/ref-test/app/view/templates/view/components/datatable.html new file mode 100644 index 0000000..5eb0cb6 --- /dev/null +++ b/ref-test/app/view/templates/view/components/datatable.html @@ -0,0 +1,28 @@ +{% extends "view/components/base.html" %} +{% block datatable_css %} + + + + + + + +{% endblock %} +{% block datatable_scripts %} + + + + + + + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/footer.html b/ref-test/app/view/templates/view/components/footer.html new file mode 100644 index 0000000..e2cea14 --- /dev/null +++ b/ref-test/app/view/templates/view/components/footer.html @@ -0,0 +1,2 @@ +

    This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at Vivek’s personal GIT repository under an MIT License.

    +

    All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.

    \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/input-forms.html b/ref-test/app/view/templates/view/components/input-forms.html new file mode 100644 index 0000000..1136a28 --- /dev/null +++ b/ref-test/app/view/templates/view/components/input-forms.html @@ -0,0 +1,4 @@ +{% extends "view/components/base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block top_alerts %} +{% endblock %} \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/navbar.html b/ref-test/app/view/templates/view/components/navbar.html new file mode 100644 index 0000000..4dbe334 --- /dev/null +++ b/ref-test/app/view/templates/view/components/navbar.html @@ -0,0 +1,117 @@ + \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/og-meta.html b/ref-test/app/view/templates/view/components/og-meta.html new file mode 100644 index 0000000..45453e1 --- /dev/null +++ b/ref-test/app/view/templates/view/components/og-meta.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/secondary-navs/tests.html b/ref-test/app/view/templates/view/components/secondary-navs/tests.html new file mode 100644 index 0000000..a007e23 --- /dev/null +++ b/ref-test/app/view/templates/view/components/secondary-navs/tests.html @@ -0,0 +1,23 @@ + \ No newline at end of file diff --git a/ref-test/app/view/templates/view/components/server-alerts.html b/ref-test/app/view/templates/view/components/server-alerts.html new file mode 100644 index 0000000..bcec7d1 --- /dev/null +++ b/ref-test/app/view/templates/view/components/server-alerts.html @@ -0,0 +1,43 @@ +{% 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" %} + + {% elif category == "success" %} + + {% elif category == "warning" %} + + {% elif category == "cookie_alert" %} + {% if not cookie_flash_flag.value %} + + {% set cookie_flash_flag.value = True %} + {% endif %} + {% else %} + + {% endif %} + {% endfor %} + {% endif %} +{% endwith %} \ No newline at end of file diff --git a/ref-test/app/view/templates/view/console.html b/ref-test/app/view/templates/view/console.html new file mode 100644 index 0000000..5de1fed --- /dev/null +++ b/ref-test/app/view/templates/view/console.html @@ -0,0 +1,116 @@ +{% extends "view/components/base.html" %} + +{% block style %} + +{% endblock %} + +{% block content %} +

    View Questions

    +
    +

    + This page lists all the questions in the selected dataset. +

    +
    +
    + +
    +
    +

    + Information +

    +

    + Questions in the test are arranged in blocks. Blocks can be of two types: Blocks of multiple related questions, and Single Questions that are not part of a block. + You can add, remove, or edit both Blockss and Questions through this editor. +

    +

    + Blocks are useful when you have a section of the test that contains multiple questions that are related to each other, for example if there is a scenario-based section where a series of questions are about the same situation. +

    +

    + Blocks can contain any number of questions within them, but cannot contain nested blocks. +

    +

    + When you set up a block, you can also add header text that will be displayed with each question. + You can use this to provide common information for a scenario across a series of questions. +

    +

    + Questions come in three types: +

      +
    • + Yes/No for when there is only a yes or no option, +
    • +
    • + Multiple Choice for your regular multiple choice questions, and +
    • +
    • + Ordered List for multiple choice questions that will be displayed in the same order as listed here. +
    • +
    +

    +

    + Normally, multiple choice questions will have the order of the options randomised. +

    +

    + Questions will be displayed to candidates in a randomised order. + Blocks of questions will be kept together, but the order within the block will also be randomised. +

    +

    + Questions can also be categorised using tags. +

    +

    + Placeholder for Questions Remaining in a Block +

    +

    + In order to show how many questions are remaining inside a block, e.g. to say ‘the next n questions are about a specific scenario’, the app uses the placeholder <block_remaining_questions>. +

    +
    +
    +

    + Question Dataset +

    + + +
    +{% endblock %} + +{% block script %} + + +{% endblock %} \ No newline at end of file diff --git a/ref-test/app/view/templates/view/index.html b/ref-test/app/view/templates/view/index.html new file mode 100644 index 0000000..78d06c9 --- /dev/null +++ b/ref-test/app/view/templates/view/index.html @@ -0,0 +1,27 @@ +{% extends "view/components/input-forms.html" %} + +{% block content %} +
    +
    + {% include "admin/components/server-alerts.html" %} +

    View Questions

    + {{ form.hidden_tag() }} +
    + {{ form.dataset(placeholder="Select Question Dataset") }} + {{ form.dataset.label }} +
    + {% include "admin/components/client-alerts.html" %} +
    +
    +
    + +
    +
    +
    +
    + +
    +{% endblock %} \ No newline at end of file diff --git a/ref-test/app/view/views.py b/ref-test/app/view/views.py new file mode 100644 index 0000000..b13a8ef --- /dev/null +++ b/ref-test/app/view/views.py @@ -0,0 +1,41 @@ +from ..forms.admin import EditDataset +from ..models import Dataset, User +from ..tools.forms import get_dataset_choices, send_errors_to_client +from ..tools.data import check_dataset_exists + +from flask import Blueprint, flash, jsonify, redirect, render_template, request +from flask.helpers import url_for +from flask_login import login_required + +view = Blueprint( + name='view', + import_name=__name__, + template_folder='templates', + static_folder='static' +) + +@view.route('/', methods=['GET','POST']) +@login_required +@check_dataset_exists +def _view(): + form = EditDataset() + form.dataset.choices = get_dataset_choices() + if request.method == 'POST': + if form.validate_on_submit(): + id = request.form.get('dataset') + return jsonify({'success': 'Selected dataset', 'redirect_to': url_for('view._view_console', id=id)}),200 + return send_errors_to_client(form=form) + form.process() + return render_template('/view/index.html', form=form) + +@view.route('//') +@login_required +@check_dataset_exists +def _view_console(id:str=None): + dataset = Dataset.query.filter_by(id=id).first() + datasets = Dataset.query.count() + users = User.query.all() + if not dataset: + flash('Invalid dataset ID.', 'error') + return redirect(url_for('admin._questions')) + return render_template('/view/console.html', dataset=dataset, datasets=datasets, users=users) \ No newline at end of file