37 Commits

Author SHA1 Message Date
f14085f4c1 Typo correction 2022-08-17 16:38:31 +01:00
be5343a4bd Added decorator to test availability of datasets
Used decorator tool to validate dataset exists on views
2022-08-17 16:37:03 +01:00
2da8eb7712 Added cross-reference to question viewer
Changed question number countint to be consistent with viewer
2022-08-17 16:36:16 +01:00
3a0abaac6a Stylistic change of name dataset to questions 2022-08-17 16:35:22 +01:00
b15f76701e Code clean up: redundant semicolons
Made variable declaration style in for loops consistent
2022-08-17 16:34:59 +01:00
02290e968c Added question viewer functionality
Added view questions panel to editor interface
Added view questions section of web site
Added links to navbars
2022-08-17 16:32:58 +01:00
294f1e42f7 Added timezone env variable 2022-08-11 17:31:20 +01:00
070ce19fcc Added instructions on updating 2022-08-11 17:19:05 +01:00
615e59fc6d Updated form error handling 2022-08-11 16:58:00 +01:00
68314a4ed2 Add handling of anonymous user when updating account 2022-08-11 16:28:47 +01:00
b90761fd2c Simplify variabe nale 2022-08-11 16:28:13 +01:00
af03193217 Change user variable name 2022-08-11 16:13:31 +01:00
730a75c44d Bugfix: reset password 2022-08-11 16:05:28 +01:00
70883db5ad Changed dockerignore stricture 2022-08-11 13:09:41 +01:00
7cefb487da Bugfix: reset password 2022-08-11 13:09:34 +01:00
2e1b01ec9b Bugfix: reset password 2022-08-11 13:02:55 +01:00
a7a5a03991 Consistency in paths for templates 2022-08-11 13:02:41 +01:00
b36c6bfd18 Bugfix: reset password 2022-08-11 12:51:17 +01:00
a613b0006b Bugfix: password reset 2022-08-11 12:44:42 +01:00
d4db8692e7 Remove debug line 2022-08-11 12:13:33 +01:00
37ad36da31 Add debug for email reset 2022-08-11 11:42:50 +01:00
d140f93d25 Bugfix: hude club field when empty 2022-08-11 11:41:57 +01:00
26a6248a61 Tidied up nnecessary imports 2022-08-11 11:39:53 +01:00
9f8ea16974 Bugfix: button display 2022-08-11 11:01:29 +01:00
bc5ec44145 Bugfix default datetime 2022-08-11 10:59:12 +01:00
ff5b19fa0b Editing text: remove repetition 2022-08-11 10:24:15 +01:00
6c50be49c6 Bugfix: default time for exam creation 2022-08-11 10:23:40 +01:00
8bfe028e2c Make certbot silent 2022-06-22 15:05:56 +01:00
519394a656 Store data in docker volume instead of project dir 2022-06-22 15:05:44 +01:00
9e1c9caec6 Updated config to have defaults for keys
Removed abstraction of data location for image build
2022-06-22 11:56:36 +01:00
ea850c9ae2 Added defaults for config keys to avoid exceptions 2022-06-22 11:45:37 +01:00
591b868920 Separated install script to avoid launch errors 2022-06-22 11:20:30 +01:00
91dc93758a Added nginx static serving editor files 2022-06-22 11:18:53 +01:00
5d27baee08 Editor flash message bugfix 2022-06-22 10:46:43 +01:00
1254cf3698 Bugfix install script dhparam 2022-06-22 09:55:59 +01:00
efab086057 Gitignore bugfix 2022-06-22 09:31:16 +01:00
06db47c597 Push production version to Master 2022-06-22 02:01:34 +01:00
55 changed files with 1878 additions and 558 deletions

View File

@ -1,5 +1,7 @@
SERVER_NAME= # URL where this will be hosted.
TZ=Europe/London # Time Zone
## Flask Configuration
SECRET_KEY= # Long, secure, secret string.
DATA=./data/

5
.gitignore vendored
View File

@ -149,4 +149,7 @@ ref-test/testing.py
database/data/
# Ignore Encryption Keyfile
.encryption.key
.encryption.key
# Ignore Data Dir
**/data/*

View File

@ -10,7 +10,7 @@ The exam client is made with accessibility in mind, and has been designed to be
## Set Up and Installation
The clien is designed to work on a server.
The app is designed to be hosted on a server.
### Pre-Requisites
@ -166,3 +166,59 @@ The app uses [OpenDyslexic](https://opendyslexic.org/), which is available on-li
It also has the option of rendering in other system fonts, but this can vary depending on your operating system.
Because these are proprietary fonts, they cannot be made available on-line in the same way as open source ones should your system not have them.
Some fonts may not display correctly as a result.
## Updating the Installation
If the app is updated, you can update the version on your installation using the following method:
### Navigate to the root folder
This will be the root folder into which you cloned the git repository when you set the app up.
### Stash your local changes
When you update the code, there is a risk the changes you made to your configuration will be overwritten.
To avoid this, use the following command:
```git stash```
This will stash the changes you made, and we can re-apply the changes once the new code has been downloaded.
If you do not have any other changes stashed, the index number of these changes should be `0` in a later step.
If there are other changes, make sure to note what the correct index number for the stashed changes is.
### Take down the Docker containers
We will need to stop the current containers with the following command:
```sudo docker compose down```
This may take a few seconds.
### Pull the updated code
Download the updated code from the Git repository:
```git pull```
This step might fail if you have any un-stashed local changed.
### Re-Apply your local configurations
Because we stashed our local configurations, we can re-apply them once again:
```git stash pop 0```
The index number (`0`) is assuming there were no other changes saved on your git repository.
If you have a different index number for the relevant changes from the above step, change this accordingly.
### Re-build the docker image
Now that we have the base code downloaded, we just need to update the docker image:
```sudo docker compose build app```
### Re-build the containers
This is the same last step as running the containers in the last step of the installation:
```sudo docker compose up -d```

View File

@ -1,5 +1,8 @@
version: '3.9'
volumes:
data:
services:
nginx:
container_name: reftest_server
@ -8,6 +11,7 @@ services:
- ./certbot:/etc/letsencrypt:ro
- ./nginx:/etc/nginx
- ./src/html:/usr/share/nginx/html/
- ./ref-test/app/editor/static:/usr/share/nginx/html/admin/editor/static
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
- ./ref-test/app/root:/usr/share/nginx/html/root
@ -30,7 +34,7 @@ services:
ports:
- 5000
volumes:
- ./ref-test/data:/ref-test/data
- data:/ref-test/data
restart: unless-stopped
networks:
- frontend

View File

@ -46,8 +46,6 @@ if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
docker compose run --rm --entrypoint "\
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
echo
fi
@ -79,7 +77,7 @@ esac
if [ $staging != "0" ]; then staging_arg="--staging"; fi
docker compose run --rm --entrypoint "\
certbot certonly --webroot -w /var/www/html \
certbot certonly --non-interactive --webroot -w /var/www/html \
$staging_arg \
$email_arg \
$domain_args \

View File

@ -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/;
@ -29,6 +30,17 @@ server {
alias /usr/share/nginx/html/admin/static/;
}
location ^~ /admin/editor/static/ {
include /etc/nginx/mime.types;
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;

View File

@ -1,5 +1,8 @@
FROM python:3.10-slim
ARG DATA=./data/
ENV DATA=$DATA
WORKDIR /ref-test
COPY . .
RUN pip install --upgrade pip && pip install -r requirements.txt
RUN chmod +x install.py && ./install.py
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]

View File

@ -1,5 +1,4 @@
from .config import Development as Config
from .install import install_app
from .config import Production as Config
from .models import User
from .extensions import bootstrap, csrf, db, login_manager, mail
@ -47,13 +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')
install_app(app)
app.register_blueprint(view, url_prefix='/admin/view')
return app

View File

@ -1,25 +1,25 @@
// Menu Highlight Scripts
const menuItems = document.getElementsByClassName('nav-link');
const menuItems = document.getElementsByClassName('nav-link')
for(let i = 0; i < menuItems.length; i++) {
if(menuItems[i].pathname == window.location.pathname) {
menuItems[i].classList.add('active');
menuItems[i].classList.add('active')
}
}
const dropdownItems = document.getElementsByClassName('dropdown-item');
const dropdownItems = document.getElementsByClassName('dropdown-item')
for(let i = 0; i< dropdownItems.length; i++) {
if(dropdownItems[i].pathname == window.location.pathname) {
dropdownItems[i].classList.add('active');
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active');
dropdownItems[i].classList.add('active')
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
}
}
// General Post Method Form Processing Script
$('form.form-post').submit(function(event) {
var $form = $(this);
var data = $form.serialize();
var url = $(this).prop('action');
var rel_success = $(this).data('rel-success');
var $form = $(this)
var data = $form.serialize()
var url = $(this).prop('action')
var rel_success = $(this).data('rel-success')
$.ajax({
url: url,
@ -28,25 +28,25 @@ $('form.form-post').submit(function(event) {
dataType: 'json',
success: function(response) {
if (response.redirect_to) {
window.location.href = response.redirect_to;
window.location.href = response.redirect_to
}
else {
window.location.href = rel_success;
window.location.href = rel_success
}
},
error: function(response) {
error_response(response);
error_response(response)
}
});
})
event.preventDefault();
});
event.preventDefault()
})
// Form Upload Questions - Special case, needs to handle files.
$('form[name=form-upload-questions]').submit(function(event) {
var $form = $(this);
var data = new FormData($form[0]);
var $form = $(this)
var data = new FormData($form[0])
var file = $('input[name=data_file]')[0].files[0]
data.append('file', file)
@ -57,21 +57,21 @@ $('form[name=form-upload-questions]').submit(function(event) {
processData: false,
contentType: false,
success: function(response) {
window.location.reload();
window.location.reload()
},
error: function(response) {
error_response(response);
error_response(response)
}
});
})
event.preventDefault();
});
event.preventDefault()
})
// Edit and Delete Test Button Handlers
$('.test-action').click(function(event) {
let id = $(this).data('id');
let action = $(this).data('action');
let id = $(this).data('id')
let action = $(this).data('action')
if (action == 'delete' || action == 'start' || action == 'end') {
$.ajax({
@ -80,25 +80,25 @@ $('.test-action').click(function(event) {
data: JSON.stringify({'id': id, 'action': action}),
contentType: 'application/json',
success: function(response) {
window.location.href = '/admin/tests/';
window.location.href = '/admin/tests/'
},
error: function(response){
error_response(response);
error_response(response)
},
});
})
} else if (action == 'edit') {
window.location.href = `/admin/test/${id}/`
}
event.preventDefault();
});
event.preventDefault()
})
// Edit Dataset Button Handlers
$('.edit-question-dataset').click(function(event) {
var id = $(this).data('id');
var action = $(this).data('action');
var disabled = $(this).hasClass('disabled');
var id = $(this).data('id')
var action = $(this).data('action')
var disabled = $(this).hasClass('disabled')
if ( !disabled ) {
if (action == 'delete') {
@ -111,25 +111,27 @@ $('.edit-question-dataset').click(function(event) {
}),
contentType: 'application/json',
success: function(response) {
window.location.reload();
window.location.reload()
},
error: function(response){
error_response(response);
error_response(response)
},
});
})
} else if (action == 'edit') {
window.location.href = `/admin/editor/${id}/`
} else if (action == 'view') {
window.location.href = `/admin/view/${id}`
} else if (action == 'download') {
window.location.href = `/admin/settings/questions/download/${id}/`
}
};
event.preventDefault();
});
}
event.preventDefault()
})
function error_response(response) {
const $alert = $("#alert-box");
$alert.html('');
const $alert = $("#alert-box")
$alert.html('')
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
@ -138,18 +140,18 @@ function error_response(response) {
${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) {
var output = ''
for (var i = 0; i < response.responseJSON.error.length; i ++) {
for (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<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>
`;
$alert.html(output);
`
$alert.html(output)
}
}
@ -167,20 +169,20 @@ $('#dismiss-cookie-alert').click(function(event){
},
dataType: 'json',
success: function(response){
console.log(response);
console.log(response)
},
error: function(response){
console.log(response);
console.log(response)
}
})
event.preventDefault();
event.preventDefault()
})
// Script for Result Actions
$('.result-action-buttons').click(function(event){
var id = $(this).data('id');
var id = $(this).data('id')
if ($(this).data('result-action') == 'generate') {
$.ajax({
@ -190,13 +192,13 @@ $('.result-action-buttons').click(function(event){
contentType: 'application/json',
dataType: 'html',
success: function(response) {
var display_window = window.open();
display_window.document.write(response);
var display_window = window.open()
display_window.document.write(response)
},
error: function(response){
error_response(response);
error_response(response)
},
});
})
} else {
var action = $(this).data('result-action')
$.ajax({
@ -206,23 +208,23 @@ $('.result-action-buttons').click(function(event){
contentType: 'application/json',
success: function(response) {
if (action == 'delete') {
window.location.href = '/admin/results/';
} else window.location.reload();
window.location.href = '/admin/results/'
} else window.location.reload()
},
error: function(response){
error_response(response);
error_response(response)
},
});
})
}
event.preventDefault();
});
event.preventDefault()
})
// Script for Deleting Time Adjustment
$('.adjustment-delete').click(function(event){
var user_code = $(this).data('user_code');
var location = window.location.href;
var user_code = $(this).data('user_code')
var location = window.location.href
location = location.replace('#', '')
$.ajax({
@ -231,12 +233,19 @@ $('.adjustment-delete').click(function(event){
data: JSON.stringify({'user_code': user_code}),
contentType: 'application/json',
success: function(response) {
window.location.reload();
window.location.reload()
},
error: function(response){
error_response(response);
error_response(response)
},
});
})
event.preventDefault();
});
event.preventDefault()
})
// Detailed Results view questions
$('.view-full-questions').click(function(event) {
var dataset = $(this).data('dataset')
window.open(`/admin/view/${dataset}`, '_blank')
event.preventDefault()
})

View File

@ -2,7 +2,7 @@
{% block content %}
<div class="form-container">
<form name="form-update-password" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
<form name="form-update-password" class="form-display form-post" action="{{ url_for('admin._update_password', **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Update Password</h2>
{{ form.hidden_tag() }}

View File

@ -24,7 +24,7 @@
</div>
{{ entry.get_email() }}
</li>
{% if entry.club %}
{% if entry.get_club() %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Club</h5>

View File

@ -77,10 +77,13 @@
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</a>
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
</li>
</ul>
</li>

View File

@ -22,7 +22,7 @@
</div>
{{ entry.get_email() }}
</li>
{% if entry.club %}
{% if entry.get_club() %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Club</h5>
@ -114,7 +114,7 @@
{{ scores.scored }}
</td>
<td>
{{scores.max}}
{{ scores.max }}
</td>
</tr>
{% endfor %}
@ -131,6 +131,7 @@
</h2>
<div id="by-question-breakdown" class="accordion-collapse collapse" aria-labelledby="by-question" data-bs-parent="#results-breakdown">
<div class="accordion-body">
<a class="view-full-questions" data-dataset="{{ entry.test.dataset.id }}">View Questions</a>
<table class="table table-striped">
<thead>
<tr>
@ -146,7 +147,7 @@
{% for question, answer in entry.answers.items() %}
<tr>
<td>
{{ question }}
{{ question|int + 1 }}
</td>
<td>
{{ answers[question|int][answer|int] }}

View File

@ -40,7 +40,7 @@
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
</td>
<td>
{% if entry.club %}
{% if entry.get_club() %}
{{ entry.get_club() }}
{% endif %}
</td>

View File

@ -57,28 +57,37 @@
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="download"
title="Download Dataset"
title="Download Questions"
>
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
</button>
</a>
<a
href="javascript:void(0)"
class="btn btn-primary view-question-dataset"
data-id="{{ element.id }}"
data-action="view"
title="View Questions"
>
<i class="bi bi-file-earmark-text-fill button-icon"></i>
</a>
<a
href="javascript:void(0)"
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="edit"
title="Edit Dataset"
title="Edit Questions"
>
<i class="bi bi-file-earmark-text-fill button-icon"></i>
</button>
<i class="bi bi-pencil-fill button-icon"></i>
</a>
<a
href="javascript:void(0)"
class="btn btn-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
data-id="{{ element.id }}"
data-action="delete"
title="Delete Dataset"
title="Delete Questions"
>
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
</button>
</a>
</td>
</tr>
{% endfor %}

View File

@ -2,14 +2,14 @@ from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser,
from ..models import Dataset, Entry, Test, User
from ..tools.auth import disable_if_logged_in, require_account_creation
from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
from ..tools.data import check_is_json, validate_json
from ..tools.data import check_dataset_exists, check_is_json, validate_json
from ..tools.test import answer_options, get_correct_answers
from flask import abort, Blueprint, jsonify, render_template, redirect, request, send_file, session
from flask.helpers import flash, url_for
from flask_login import current_user, login_required
from datetime import date, datetime
from datetime import date, datetime, timedelta
from json import loads
from os import path
import secrets
@ -91,7 +91,7 @@ def _register():
flash(message=message, category='error')
return jsonify({'error': message}), 401
return send_errors_to_client(form=form)
return render_template('admin/auth/register.html', form=form)
return render_template('/admin/auth/register.html', form=form)
@admin.route('/reset/', methods=['GET','POST'])
def _reset():
@ -117,7 +117,8 @@ def _reset():
user.clear_reset_tokens()
if request.args.get('verification') == verification_token:
form = UpdatePassword()
return render_template('/auth/update_password.html', form=form, user=user.id)
session['user'] = user.id
return render_template('/admin/auth/update-password.html', form=form)
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error')
return render_template('/admin/auth/reset.html', form=form)
@ -126,7 +127,7 @@ def _reset():
def _update_password():
form = UpdatePassword()
if form.validate_on_submit():
user = request.form.get('user')
user = session.pop('user')
user = User.query.filter_by(id=user).first()
user.update(password=request.form.get('password'))
session['remembered_username'] = user.get_username()
@ -246,18 +247,17 @@ def _download(id:str):
@admin.route('/tests/<string:filter>/', methods=['GET'])
@admin.route('/tests/', methods=['GET'])
@login_required
@check_dataset_exists
def _tests(filter:str=None):
datasets = Dataset.query.all()
tests = None
_tests = Test.query.all()
form = None
now = datetime.now()
if not datasets:
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
return redirect(url_for('admin._questions'))
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
if filter == 'create':
form = CreateTest()
form.start_date.default = datetime.now()
form.expiry_date.default = date.today() + timedelta(days=1)
form.time_limit.choices = get_time_options()
form.dataset.choices = get_dataset_choices()
form.time_limit.default='none'

View File

@ -5,7 +5,7 @@ load_dotenv('../.env')
class Config(object):
APP_HOST = '0.0.0.0'
DATA = os.getenv('DATA')
DATA = './data/'
DEBUG = False
TESTING = False
SECRET_KEY = os.getenv('SECRET_KEY')
@ -15,16 +15,16 @@ class Config(object):
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAIL_SERVER = os.getenv('MAIL_SERVER')
MAIL_PORT = int(os.getenv('MAIL_PORT'))
MAIL_PORT = int(os.getenv('MAIL_PORT') or 25)
MAIL_USE_TLS = False
MAIL_USE_SSL = False
MAIL_DEBUG = False
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS'))
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS') or 25)
MAIL_SUPPRESS_SEND = False
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS'))
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS') or True)
class Production(Config):
pass

View File

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

View File

@ -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)
}
})
})
})
// 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 = `<strong>Question ${block['q_no'] + 1}.</strong> ${block['text']}`
obj.append(text)
question_body = document.createElement('div')
question_body.className ='question-body'
type = document.createElement('p')
type.innerHTML = `<strong>Question Type:</strong> ${block['q_type']}`
question_body.append(type)
options = document.createElement('p')
options.innerHTML = '<strong>Options:</strong>'
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 += ' <span class="badge rounded-pill bg-success">Correct</span>'
}
option_list.append(option)
}
options.append(option_list)
question_body.append(options)
tags = document.createElement('p')
tags.innerHTML = `<strong>Tags:</strong>`
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 = `<strong>Block ${i+1}.</strong> ${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 = `<strong>Question ${question['q_no'] + 1}.</strong> ${question['text']}`
block_question.append(text)
question_body = document.createElement('div')
question_body.className ='question-body'
type = document.createElement('p')
type.innerHTML = `<strong>Question Type:</strong> ${question['q_type']}`
question_body.append(type)
options = document.createElement('p')
options.innerHTML = '<strong>Options:</strong>'
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 += ' <span class="badge rounded-pill bg-success">Correct</span>'
}
option_list.append(option)
}
options.append(option_list)
question_body.append(options)
tags = document.createElement('p')
tags.innerHTML = `<strong>Tags:</strong>`
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)
}
}

View File

@ -57,7 +57,7 @@ function error_response(response) {
`);
} else if (response.responseJSON.error instanceof Array) {
var output = ''
for (var i = 0; i < response.responseJSON.error.length; i ++) {
for (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>

View File

@ -77,10 +77,13 @@
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</a>
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
</li>
</ul>
</li>

View File

@ -11,11 +11,12 @@
<h1>Editor</h1>
<div class="container">
<p class="lead">
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.
</p>
</div>
<div class="container control-panel">
<button class="btn btn-primary" aria-title="Infrmation" title="Information"><i class="bi bi-info-circle-fill"></i></button>
<button class="btn btn-primary" aria-title="Information" title="Information" data-action="info"><i class="bi bi-info-circle-fill"></i></button>
<button class="btn btn-success" aria-title="View Questions" title="View Questions" data-action="view"><i class="bi bi-book-fill"></i></button>
</div>
<div class="container info-panel">
<h3>
@ -77,9 +78,11 @@
In order to show how many questions are remaining inside a block, e.g. to say &lsquo;the next n questions are about a specific scenario&rsquo;, use the placeholder <code>&lt;block_remaining_questions&gt;</code>.
</p>
</div>
<div class="container viewer-panel">
</div>
<div class="container editor-panel">
<h3>
Edit Dataset
Edit Questions
</h3>
<div class="container dataset-metadata">
<div class="input-group mb-3">
@ -98,7 +101,7 @@
<span class="input-group-text">Last Updated</span>
<span class="form-control">
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
</select>
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">

View File

@ -4,7 +4,7 @@
<div class="form-container">
<form name="form-editor" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for(request.endpoint, **request.view_args) }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form">Dataset Editor</h2>
<h2 class="form">Edit Questions</h2>
{{ form.hidden_tag() }}
<div class="form-select-input">
{{ form.dataset(placeholder="Select Question Dataset") }}

View File

@ -1,6 +1,7 @@
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
@ -27,6 +28,7 @@ def _editor():
return render_template('/editor/index.html', form=form)
@editor.route('/<string:id>/')
@check_dataset_exists
@login_required
def _editor_console(id:str=None):
dataset = Dataset.query.filter_by(id=id).first()

View File

@ -6,8 +6,6 @@ from wtforms import BooleanField, IntegerField, PasswordField, SelectField, Stri
from wtforms.fields import DateTimeLocalField
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
from datetime import date, datetime, timedelta
class Login(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
@ -51,8 +49,8 @@ class UpdateAccount(FlaskForm):
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
class CreateTest(FlaskForm):
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = datetime.now() )
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = date.today() + timedelta(days=1) )
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
time_limit = SelectField('Time Limit')
dataset = SelectField('Question Dataset')

View File

@ -1,34 +0,0 @@
from .extensions import db
from .tools.data import save
from .tools.logs import write
from sqlalchemy_utils import create_database, database_exists
from cryptography.fernet import Fernet
from os import mkdir, path
from pathlib import Path
def install_app(app):
with app.app_context():
data = Path(app.config.get('DATA'))
database_uri = app.config.get('SQLALCHEMY_DATABASE_URI')
if not path.isdir(f'./{data}'): mkdir(f'./{data}')
if not path.isdir(f'./{data}/questions'): mkdir(f'./{data}/questions')
if not path.isfile(f'./{data}/.gitignore'):
with open(f'./{data}/.gitignore', 'a+') as file: file.write(f'*')
if not path.isfile(f'./{data}/config.json'): save({}, 'config.json')
if not path.isdir(f'./{data}/logs'): mkdir(f'./{data}/logs')
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/system.log'): write('system.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
if not database_exists(database_uri):
create_database(database_uri)
write('system.log', 'No database found. Creating a new database.')
from .models import Entry, Dataset, Test, User
db.create_all()
write('system.log', 'Creating database schema.')
if not path.isfile(f'./{data}/.encryption.key'):
write('system.log', 'No encryption key found. Generating new encryption key.')
with open(f'./{data}/.encryption.key', 'wb') as key_file:
key = Fernet.generate_key()
key_file.write(key)

View File

@ -102,7 +102,7 @@ class Dataset(db.Model):
with open(file_path, 'w') as file:
dump(data, file, indent=2)
write('system.log', f'Dataset {self.id} edited by {current_user.get_username()}.')
flash(f'Dataset {self.name} successfully edited.', 'success')
flash(f'Dataset {self.get_name()} successfully edited.', 'success')
db.session.add(self)
db.session.commit()
return True, 'Dataset successfully edited.'

View File

@ -194,7 +194,8 @@ class User(UserMixin, db.Model):
if entry.get_email() == email and not entry == self: return False, f'The email address {email} is already in use.'
self.set_email(email)
db.session.commit()
write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.')
_current_user = current_user.get_username() if current_user.is_authenticated else 'anonymous'
write('system.log', f'Information for user {self.get_username()} has been updated by {_current_user}.')
if notify:
message = Message(
subject='RefTest | Account Update',
@ -202,7 +203,7 @@ class User(UserMixin, db.Model):
bcc=[old_email,current_user.get_email()],
body=f"""
Hello {self.get_username()},\n\n
Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.\n\n
Your administrator account for the SKA RefTest App has been updated by {_current_user}.\n\n
Your new account details are as follows:\n\n
Email: {email}\n
Password: {password if password else '<same as old>'}\n\n
@ -213,7 +214,7 @@ class User(UserMixin, db.Model):
""",
html=f"""
<p>Hello {self.get_username()},</p>
<p>Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.</p>
<p>Your administrator account for the SKA RefTest App has been updated by {_current_user}.</p>
<p>Your new account details are as follows:</p>
<p>Email: {email} <br/> Password: <strong>{password if password else '&lt;same as old&gt;'}</strong></p>
<p>You can update your email address and password by logging in to the admin console using the following URL:</p>

View File

@ -1,142 +1,142 @@
// Bind Listeners
$("input[name='font-select']").change(function(){
let $choice = $(this).val();
set_font($choice);
});
let $choice = $(this).val()
set_font($choice)
})
$("input[name='font-size']").change(function(){
let $choice = $(this).val();
set_font_size($choice);
});
let $choice = $(this).val()
set_font_size($choice)
})
$("input[name='bg-select']").change(function(){
let $choice = $(this).val();
set_bg_colour($choice);
});
let $choice = $(this).val()
set_bg_colour($choice)
})
$(".bg-select-area").click(function(event){
$(this).find("input[name='bg-select']").prop("checked", true).change();
});
$(this).find("input[name='bg-select']").prop("checked", true).change()
})
$("#btn-toggle-navigator").click(function(event){
check_answered();
update_navigator();
check_answered()
update_navigator()
if ($quiz_navigator.is(":hidden")) {
if ($quiz_settings.is(":visible")) {
toggle_settings = true;
$quiz_settings.fadeOut();
toggle_settings = true
$quiz_settings.fadeOut()
}
$quiz_render.fadeOut();
$quiz_navigator.fadeIn();
$(".navigator-text").fadeIn();
$(".review-text").fadeOut();
toggle_navigator = false;
$(window).scrollTop(0);
$quiz_render.fadeOut()
$quiz_navigator.fadeIn()
$(".navigator-text").fadeIn()
$(".review-text").fadeOut()
toggle_navigator = false
$(window).scrollTop(0)
} else {
$quiz_navigator.fadeOut();
$quiz_navigator.fadeOut()
if (toggle_settings) {
$quiz_settings.fadeIn();
$(window).scrollTop(0);
toggle_settings = false;
$quiz_settings.fadeIn()
$(window).scrollTop(0)
toggle_settings = false
} else {
$quiz_render.fadeIn();
$(window).scrollTop(0);
$quiz_render.fadeIn()
$(window).scrollTop(0)
}
}
event.preventDefault();
});
event.preventDefault()
})
$("#btn-toggle-settings").click(function(event){
if (($quiz_settings).is(":hidden")) {
if ($quiz_navigator.is(":visible")) {
toggle_navigator = true;
$quiz_navigator.fadeOut();
toggle_navigator = true
$quiz_navigator.fadeOut()
}
$quiz_render.fadeOut();
$quiz_settings.fadeIn();
$(window).scrollTop(0);
toggle_settings = false;
$quiz_render.fadeOut()
$quiz_settings.fadeIn()
$(window).scrollTop(0)
toggle_settings = false
} else {
$quiz_settings.fadeOut();
$quiz_settings.fadeOut()
if (toggle_navigator) {
$quiz_navigator.fadeIn();
toggle_navigator = false;
$(window).scrollTop(0);
$quiz_navigator.fadeIn()
toggle_navigator = false
$(window).scrollTop(0)
} else {
$quiz_render.fadeIn();
$(window).scrollTop(0);
$quiz_render.fadeIn()
$(window).scrollTop(0)
}
}
event.preventDefault();
});
event.preventDefault()
})
$(".btn-quiz-return").click(function(event){
$quiz_navigator.fadeOut();
$quiz_settings.fadeOut();
$quiz_render.fadeIn();
$(window).scrollTop(0);
toggle_settings = false;
toggle_navigator = false;
event.preventDefault();
});
$quiz_navigator.fadeOut()
$quiz_settings.fadeOut()
$quiz_render.fadeIn()
$(window).scrollTop(0)
toggle_settings = false
toggle_navigator = false
event.preventDefault()
})
$(".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).prop("name"));
$quiz_navigator.fadeOut();
$quiz_render.fadeIn();
$question_title.focus();
$(window).scrollTop(0);
toggle_navigator = false;
toggle_settings = false;
render_question();
check_flag();
event.preventDefault();
});
check_answered()
update_navigator()
current_question = parseInt($(this).prop("name"))
$quiz_navigator.fadeOut()
$quiz_render.fadeIn()
$question_title.focus()
$(window).scrollTop(0)
toggle_navigator = false
toggle_settings = false
render_question()
check_flag()
event.preventDefault()
})
$(".q-question-nav").click(function(event){
check_answered();
update_navigator();
check_answered()
update_navigator()
if ($(this).prop("id") == "q-nav-next") {
if (current_question < questions.length) {
current_question ++;
current_question ++
}
} else if ($(this).prop("id") == "q-nav-prev") {
if (current_question > 0) {
current_question --;
current_question --
}
} else if ($(this).hasClass("q-navigator-button")) {
current_question = $(this).prop("name");
$quiz_render.fadeIn();
$quiz_navigator.fadeOut();
toggle_navigator = false;
toggle_settings = false;
current_question = $(this).prop("name")
$quiz_render.fadeIn()
$quiz_navigator.fadeOut()
toggle_navigator = false
toggle_settings = false
}
render_question();
check_flag();
event.preventDefault();
});
render_question()
check_flag()
event.preventDefault()
})
$("#q-nav-flag").click(function(event){
if (question_status[current_question] != 1) {
question_status[current_question] = 1;
$(this).removeClass().addClass("btn btn-warning");
$(this).prop("title", "Question Flagged for revision. Click to un-flag.");
question_status[current_question] = 1
$(this).removeClass().addClass("btn btn-warning")
$(this).prop("title", "Question Flagged for revision. Click to un-flag.")
} else {
question_status[current_question] = 0;
$(this).removeClass().addClass("btn btn-secondary");
$(this).prop("title", "Question Un-Flagged. Click to flag for revision.");
question_status[current_question] = 0
$(this).removeClass().addClass("btn btn-secondary")
$(this).prop("title", "Question Un-Flagged. Click to flag for revision.")
}
window.localStorage.setItem('question_status', JSON.stringify(question_status));
update_navigator();
event.preventDefault();
});
window.localStorage.setItem('question_status', JSON.stringify(question_status))
update_navigator()
event.preventDefault()
})
$("#btn-start-quiz").click(function(event){
@ -146,29 +146,29 @@ $("#btn-start-quiz").click(function(event){
data: JSON.stringify({'id': id}),
contentType: "application/json",
success: function(response) {
$(this).fadeOut();
$(".btn-quiz-return").fadeIn();
$(".quiz-console").fadeIn();
$("#quiz-settings").fadeOut();
$("#quiz-navigator").fadeOut();
$(".quiz-start-text").fadeOut();
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();
$("#btn-start-quiz").fadeOut()
$(".btn-quiz-return").fadeIn()
$(".quiz-console").fadeIn()
$("#quiz-settings").fadeOut()
$("#quiz-navigator").fadeOut()
$(".quiz-start-text").fadeOut()
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_limit != null) {
$("#q-timer-widget").fadeIn();
time_remaining = get_time_remaining();
clock = setInterval(timer, 1000);
$("#q-timer-widget").fadeIn()
time_remaining = get_time_remaining()
clock = setInterval(timer, 1000)
}
if (response.time_adjustment > 0) {
const $alert = $("#alert-box");
const $alert = $("#alert-box")
$alert.html(
`<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Alert"></i>
@ -176,50 +176,50 @@ $("#btn-start-quiz").click(function(event){
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`
);
$alert.focus();
)
$alert.focus()
}
},
error: function(response) {
error_response(response);
error_response(response)
}
});
})
event.preventDefault();
});
event.preventDefault()
})
$("#quiz-question-options").on("change", ".quiz-option", function(event){
$name = parseInt($(this).prop("name"));
$value = $(this).prop("value");
answers[$name] = $value;
window.localStorage.setItem('answers', JSON.stringify(answers));
});
$name = parseInt($(this).prop("name"))
$value = $(this).prop("value")
answers[$name] = $value
window.localStorage.setItem('answers', JSON.stringify(answers))
})
$("#q-review-answers").click(function(event){
check_answered();
update_navigator();
check_answered()
update_navigator()
if ($quiz_navigator.is(":hidden")) {
if ($quiz_settings.is(":visible")) {
toggle_settings = true;
$quiz_settings.fadeOut();
toggle_settings = true
$quiz_settings.fadeOut()
}
$quiz_render.fadeOut();
$quiz_navigator.fadeIn();
$(".navigator-text").fadeOut();
$(".review-text").fadeIn();
toggle_navigator = false;
$(window).scrollTop(0);
$quiz_render.fadeOut()
$quiz_navigator.fadeIn()
$(".navigator-text").fadeOut()
$(".review-text").fadeIn()
toggle_navigator = false
$(window).scrollTop(0)
} else {
$quiz_navigator.fadeOut();
$quiz_navigator.fadeOut()
if (toggle_settings) {
$quiz_settings.fadeIn();
toggle_settings = false;
$quiz_settings.fadeIn()
toggle_settings = false
} else {
$quiz_render.fadeIn();
$quiz_render.fadeIn()
}
}
event.preventDefault();
});
event.preventDefault()
})
$(".quiz-button-submit").click(function(event){
let submission = {
@ -233,16 +233,16 @@ $(".quiz-button-submit").click(function(event){
data: JSON.stringify(submission),
contentType: "application/json",
success: function(response) {
window.localStorage.clear();
window.location.href = `/result/`;
window.localStorage.clear()
window.location.href = `/result/`
},
error: function(response) {
error_response(response);
error_response(response)
}
});
})
event.preventDefault();
});
event.preventDefault()
})
// Functions
@ -251,17 +251,17 @@ function set_font(value = 'osdefault') {
for (let i = 0; i < font_styles.length; i ++) {
if (font_styles[i] != value) {
$("body").removeClass( `q-f-${font_styles[i]}` );
};
};
$("body").removeClass( `q-f-${font_styles[i]}` )
}
}
if (value != 'osdefault') {
$("body").addClass(`q-f-${value}`);
};
$("body").addClass(`q-f-${value}`)
}
display_settings['font-select'] = value;
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
$('input[name="font-select"][value="' + value + '"]').prop('checked', true);
display_settings['font-select'] = value
window.localStorage.setItem('display_settings', JSON.stringify(display_settings))
$('input[name="font-select"][value="' + value + '"]').prop('checked', true)
}
function set_font_size(value = '14pt') {
@ -269,17 +269,17 @@ function set_font_size(value = '14pt') {
for (let i = 0; i < font_sizes.length; i ++) {
if (font_sizes[i] != value) {
$("body").removeClass( `q-f-${font_sizes[i]}` );
};
};
$("body").removeClass( `q-f-${font_sizes[i]}` )
}
}
if (value != '14pt') {
$("body").addClass(`q-f-${value}`);
};
$("body").addClass(`q-f-${value}`)
}
display_settings['font-size'] = value;
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
$('input[name="font-size"][value="' + value + '"]').prop('checked', true);
display_settings['font-size'] = value
window.localStorage.setItem('display_settings', JSON.stringify(display_settings))
$('input[name="font-size"][value="' + value + '"]').prop('checked', true)
}
function set_bg_colour(value = 'bg-light') {
@ -287,34 +287,34 @@ function set_bg_colour(value = 'bg-light') {
for (let i = 0; i < backgrounds.length; i ++) {
if (backgrounds[i] != value) {
$("body").removeClass(backgrounds[i]);
$("body").removeClass(backgrounds[i])
if (backgrounds[i] == 'bg-dark') {
$("body").removeClass('text-light');
};
$("body").removeClass('text-light')
}
if (backgrounds[i] == 'alert-primary' || backgrounds[i] == 'alert-secondary' || backgrounds[i] == 'alert-dark') {
$("body").removeClass('text-dark');
};
};
};
$("body").removeClass('text-dark')
}
}
}
$("body").addClass(value);
$("body").addClass(value)
if (value == 'bg-dark') {
$("body").addClass('text-light');
};
$("body").addClass('text-light')
}
if (value == 'alert-primary' || value == 'alert-secondary' || value == 'alert-dark') {
$("body").addClass('text-dark');
};
$("body").addClass('text-dark')
}
display_settings['bg-select'] = value;
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
$('input[name="bg-select"][value="' + value + '"]').prop('checked', true);
display_settings['bg-select'] = value
window.localStorage.setItem('display_settings', JSON.stringify(display_settings))
$('input[name="bg-select"][value="' + value + '"]').prop('checked', true)
}
function get_settings_from_storage() {
let display_settings = window.localStorage.getItem('display_settings')
if (display_settings != null) {
return JSON.parse(display_settings);
};
return JSON.parse(display_settings)
}
return {
'font-select': 'osdefault',
'font-size': '14pt',
@ -323,133 +323,133 @@ function get_settings_from_storage() {
}
function apply_settings(settings) {
set_font(settings['font-select']);
set_font_size(settings['font-size']);
set_bg_colour(settings['bg-select']);
set_font(settings['font-select'])
set_font_size(settings['font-size'])
set_bg_colour(settings['bg-select'])
}
function render_question() {
if (current_question == 0) {
$nav_prev.addClass('disabled');
$nav_prev.addClass('disabled')
}
if (current_question == questions.length - 1) {
$nav_next.addClass('disabled');
$nav_next.addClass('disabled')
}
if ($nav_prev.hasClass('disabled') && current_question > 0) {
$nav_prev.removeClass('disabled');
$nav_prev.removeClass('disabled')
}
if ($nav_next.hasClass('disabled') && current_question < questions.length - 1) {
$nav_next.removeClass('disabled');
$nav_next.removeClass('disabled')
}
var question = questions[current_question];
let header_text = question.question_header;
var block_length = 0;
var question = questions[current_question]
let header_text = question.question_header
var block_length = 0
if ('block_length' in question) {
block_length = question['block_length'];
};
var block_q_no = 0;
block_length = question['block_length']
}
var block_q_no = 0
if ('block_q_no' in question) {
block_q_no = question['block_q_no'];
block_q_no = question['block_q_no']
}
let remaining_qs = (block_length - block_q_no).toString();
let remaining_qs = (block_length - block_q_no).toString()
if (block_length - block_q_no > 1) {
remaining_qs += ' questions';
remaining_qs += ' questions'
} else {
remaining_qs += ' question';
remaining_qs += ' question'
}
header_text = header_text.replace('<block_remaining_questions>', remaining_qs);
$question_header.html(header_text);
$question_text.html(question.text);
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`);
header_text = header_text.replace('<block_remaining_questions>', remaining_qs)
$question_header.html(header_text)
$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 = '';
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][0]) {
add_checked = 'checked';
add_checked = 'checked'
}
}
options_output += `<div class="form-check">
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i][0]}" ${add_checked}>
<label for="q${current_question}-${i}" class="form-check-label">${options[i][1]}</label>
</div>`;
</div>`
}
$question_options.html(options_output);
let skipped = count_questions(-1);
let answered = count_questions(2);
let flagged = count_questions(1);
$question_options.html(options_output)
let skipped = count_questions(-1)
let answered = count_questions(2)
let flagged = count_questions(1)
$progress_skipped.prop('title', `Skipped: ${skipped}`);
$progress_skipped.prop('aria-valuenow', skipped);
$progress_skipped.css('width', `${skipped}%`);
$skipped_count.text(`Skipped: ${skipped}`);
$progress_skipped.prop('title', `Skipped: ${skipped}`)
$progress_skipped.prop('aria-valuenow', skipped)
$progress_skipped.css('width', `${skipped}%`)
$skipped_count.text(`Skipped: ${skipped}`)
if (skipped < 1) {
$skipped_count.fadeOut()
} else {
$skipped_count.fadeIn()
}
$progress_flagged.prop('title', `Flagged: ${flagged}`);
$progress_flagged.prop('aria-valuenow', flagged);
$progress_flagged.css('width', `${flagged}%`);
$flagged_count.text(`Flagged: ${flagged}`);
$progress_flagged.prop('title', `Flagged: ${flagged}`)
$progress_flagged.prop('aria-valuenow', flagged)
$progress_flagged.css('width', `${flagged}%`)
$flagged_count.text(`Flagged: ${flagged}`)
if (flagged < 1) {
$flagged_count.fadeOut()
} else {
$flagged_count.fadeIn()
}
$progress_answered.prop('title', `Answered: ${answered}`);
$progress_answered.prop('aria-valuenow', answered);
$progress_answered.css('width', `${answered}%`);
$answered_count.text(`Answered: ${answered}`);
$progress_answered.prop('title', `Answered: ${answered}`)
$progress_answered.prop('aria-valuenow', answered)
$progress_answered.css('width', `${answered}%`)
$answered_count.text(`Answered: ${answered}`)
if (answered < 1) {
$answered_count.fadeOut()
} else {
$answered_count.fadeIn()
}
$question_title.focus();
$(window).scrollTop(0);
$question_title.focus()
$(window).scrollTop(0)
}
function check_answered() {
var question = questions[current_question];
var name = question.q_no;
var question = questions[current_question]
var name = question.q_no
if (question_status[current_question] == 0 || question_status[current_question] == -1) {
if (!$(`input[name='${name}']:checked`).val()) {
question_status[current_question] = -1;
question_status[current_question] = -1
} else {
question_status[current_question] = 2;
question_status[current_question] = 2
}
window.localStorage.setItem('question_status', JSON.stringify(question_status));
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));
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 progress-bar-striped');
$nav_flag.prop("title", "Question Incomplete. Click to flag for revision.");
break;
$nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped')
$nav_flag.prop("title", "Question Incomplete. Click to flag for revision.")
break
case 1:
$nav_flag.removeClass().addClass('btn btn-warning');
$nav_flag.prop("title", "Question Flagged for revision. Click to un-flag.");
break;
$nav_flag.removeClass().addClass('btn btn-warning')
$nav_flag.prop("title", "Question Flagged for revision. Click to un-flag.")
break
case 2:
$nav_flag.removeClass().addClass('btn btn-success');
$nav_flag.prop("title", "Question Answered. Click to flag for revision.");
break;
$nav_flag.removeClass().addClass('btn btn-success')
$nav_flag.prop("title", "Question Answered. Click to flag for revision.")
break
default:
$nav_flag.removeClass().addClass('btn btn-secondary');
$nav_flag.prop("title", "Question Un-Flagged. Click to flag for revision.");
$nav_flag.removeClass().addClass('btn btn-secondary')
$nav_flag.prop("title", "Question Un-Flagged. Click to flag for revision.")
}
}
@ -457,31 +457,31 @@ function build_navigator() {
$nav_container.html('')
var output = ''
for (let i = 0; i < questions.length; i ++) {
let add_class, add_href, add_status = '';
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;
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;
add_class = 'btn-warning'
add_href = 'href="#"'
add_status = 'Flagged'
break
case 2:
add_class = 'btn-success';
add_href = 'href="#"';
add_status = 'Answered';
break;
add_class = 'btn-success'
add_href = 'href="#"'
add_status = 'Answered'
break
default:
add_class = 'btn-secondary disabled';
add_href = '';
add_status = 'Unseen';
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>`;
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);
$nav_container.html(output)
}
function update_navigator() {
@ -489,162 +489,162 @@ function update_navigator() {
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.prop("title", `Question ${current_question + 1}: Incomplete`);
break;
button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped")
button.prop("title", `Question ${current_question + 1}: Incomplete`)
break
case 1:
button.removeClass().addClass("q-navigator-button btn btn-warning");
button.prop("title", `Question ${current_question + 1}: Flagged`);
break;
button.removeClass().addClass("q-navigator-button btn btn-warning")
button.prop("title", `Question ${current_question + 1}: Flagged`)
break
case 2:
button.removeClass().addClass("q-navigator-button btn btn-success");
button.prop("title", `Question ${current_question + 1}: Answered`);
break;
button.removeClass().addClass("q-navigator-button btn btn-success")
button.prop("title", `Question ${current_question + 1}: Answered`)
break
default:
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled");
button.prop("title", `Question ${current_question + 1}: Unseen`);
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled")
button.prop("title", `Question ${current_question + 1}: Unseen`)
}
}
}
function start() {
$("#btn-start-quiz").fadeOut();
$(".btn-quiz-return").fadeIn();
$(".quiz-console").fadeIn();
$("#quiz-settings").fadeOut();
$("#quiz-navigator").fadeOut();
$(".quiz-start-text").fadeOut();
$("#btn-start-quiz").fadeOut()
$(".btn-quiz-return").fadeIn()
$(".quiz-console").fadeIn()
$("#quiz-settings").fadeOut()
$("#quiz-navigator").fadeOut()
$(".quiz-start-text").fadeOut()
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');
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');
let get_answers = window.localStorage.getItem('answers')
if (get_answers != null) {
answers = JSON.parse(get_answers);
answers = JSON.parse(get_answers)
}
let get_status = window.localStorage.getItem('question_status');
let get_status = window.localStorage.getItem('question_status')
if (get_status != null) {
question_status = JSON.parse(get_status);
question_status = JSON.parse(get_status)
}
render_question();
build_navigator();
check_flag();
render_question()
build_navigator()
check_flag()
if (time_limit != 'null' && time_limit != null) {
$("#q-timer-widget").fadeIn();
time_remaining = get_time_remaining();
clock = setInterval(timer, 1000);
$("#q-timer-widget").fadeIn()
time_remaining = get_time_remaining()
clock = setInterval(timer, 1000)
}
}
function check_started() {
let questions = window.localStorage.getItem('questions');
let time_limit = window.localStorage.getItem('time_limit');
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();
start()
}
}
function get_time_remaining() {
var end_time = new Date(time_limit).getTime();
var _start_time = new Date().getTime();
return end_time - _start_time;
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);
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 = '';
var timer_display = ''
if (hours > 0) {
timer_display = `${hours.toString()}:`;
timer_display = `${hours.toString()}:`
}
if (minutes > 0 || hours > 0) {
if (minutes < 10) {
timer_display += `0${minutes.toString()}:`;
timer_display += `0${minutes.toString()}:`
} else {
timer_display += `${minutes.toString()}:`;
timer_display += `${minutes.toString()}:`
}
}
if (seconds < 10) {
timer_display += `0${seconds.toString()}`;
timer_display += `0${seconds.toString()}`
} else {
timer_display += seconds.toString();
timer_display += seconds.toString()
}
$timer.html(timer_display);
$timer.html(timer_display)
time_remaining -= 1000
} else {
$timer.html('Expired');
clearInterval(clock);
$timer.html('Expired')
clearInterval(clock)
stop()
}
}
function stop() {
$quiz_render.fadeOut();
$quiz_navigator.fadeOut();
$quiz_timeout.fadeIn();
$("#btn-toggle-navigator").addClass('disabled');
$quiz_render.fadeOut()
$quiz_navigator.fadeOut()
$quiz_timeout.fadeIn()
$("#btn-toggle-navigator").addClass('disabled')
$("#btn-toggle-settings").addClass('disabled')
}
function count_questions(status) {
output = 0;
output = 0
for (let i = 0; i < Object.keys(question_status).length; i++) {
key = Object.keys(question_status)[i];
key = Object.keys(question_status)[i]
if (question_status[key] == status){
output ++;
output ++
}
}
return output;
return output
}
// Variable Definitions
const id = window.localStorage.getItem('id');
const id = window.localStorage.getItem('id')
var current_question = 0;
var total_questions = 0;
var question_status = {};
var answers = {};
var questions = [];
var time_limit, start_time, time_remaining;
var current_question = 0
var total_questions = 0
var question_status = {}
var answers = {}
var questions = []
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_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");
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;
var toggle_settings = false
var toggle_navigator = false
const $question_title = $("#quiz-question-title");
const $question_header = $("#quiz-question-header");
const $question_text = $("#quiz-question-text");
const $question_options = $("#quiz-question-options");
const $question_title = $("#quiz-question-title")
const $question_header = $("#quiz-question-header")
const $question_text = $("#quiz-question-text")
const $question_options = $("#quiz-question-options")
const $progress_skipped = $("#skipped-bar");
const $progress_answered = $("#answered-bar");
const $progress_flagged = $("#flagged-bar");
const $skipped_count = $("#skipped-count");
const $answered_count = $("#answered-count");
const $flagged_count = $("#flagged-count");
const $progress_skipped = $("#skipped-bar")
const $progress_answered = $("#answered-bar")
const $progress_flagged = $("#flagged-bar")
const $skipped_count = $("#skipped-count")
const $answered_count = $("#answered-count")
const $flagged_count = $("#flagged-count")
// Execution on Load
apply_settings(display_settings);
check_started();
apply_settings(display_settings)
check_started()

View File

@ -1,21 +1,21 @@
$(document).ready(function() {
$("#od-font-test").click(function(){
$("body").css("font-family", "opendyslexic3regular")
});
})
$('.test-code-input').keyup(function() {
var input = $(this).val().split("-").join("").split("—").join("");
var input = $(this).val().split("-").join("").split("—").join("")
if (input.length > 0) {
input = input.match(new RegExp('.{1,4}', 'g')).join("—");
input = input.match(new RegExp('.{1,4}', 'g')).join("—")
}
$(this).val(input);
});
});
$(this).val(input)
})
})
$('form[name=form-quiz-start]').submit(function(event) {
var $form = $(this);
var data = $form.serialize();
var $form = $(this)
var data = $form.serialize()
$.ajax({
url: window.location.pathname,
@ -24,21 +24,21 @@ $('form[name=form-quiz-start]').submit(function(event) {
dataType: 'json',
success: function(response) {
var id = response.id
window.localStorage.setItem('id', id);
window.location.href = `/quiz/`;
window.localStorage.setItem('id', id)
window.location.href = `/quiz/`
},
error: function(response) {
error_response(response);
error_response(response)
}
});
})
event.preventDefault();
});
event.preventDefault()
})
function error_response(response) {
const $alert = $("#alert-box");
$alert.html('');
const $alert = $("#alert-box")
$alert.html('')
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
@ -47,18 +47,18 @@ function error_response(response) {
${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) {
var output = ''
for (var i = 0; i < response.responseJSON.error.length; i ++) {
for (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<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>
`;
$alert.html(output);
`
$alert.html(output)
}
}
}
@ -74,13 +74,13 @@ $('#dismiss-cookie-alert').click(function(event){
},
dataType: 'json',
success: function(response){
console.log(response);
console.log(response)
},
error: function(response){
console.log(response);
console.log(response)
}
})
event.preventDefault();
event.preventDefault()
})

View File

@ -123,7 +123,7 @@
<div class="container question-container quiz-start-text">
<h4 class="question-title">Sample Question</h4>
<p class="question-header">
Korfball is a mixed-sex, controlled-contact, indoor, invasion ball sport. The sport originated in the Netherlands. It is a mixed-sex team sport. Its governing body is the International Korball Federation. There are numerous korfball leagues and associations around the world. A korfball match is officiated by a referee.
Korfball is a mixed-sex, controlled-contact, indoor, invasion, team ball sport. The sport originated in the Netherlands. Its governing body is the International Korball Federation. There are numerous korfball leagues and associations around the world. A korfball match is officiated by a referee.
</p>
<p class="question-text">
In order to be a referee, what do you need to know?

View File

@ -11,7 +11,7 @@
<strong class="results-details">Email Address</strong>: {{ entry.get_email() }} <br />
{% if entry.club %}
{% if entry.get_club() %}
<strong class="results-details">Club</strong>: {{ entry.get_club() }} <br />
{% endif%}

View File

@ -1,21 +1,21 @@
$(document).ready(function() {
$("#od-font-test").click(function(){
$("body").css("font-family", "opendyslexic3regular")
});
})
$('.test-code-input').keyup(function() {
var input = $(this).val().split("-").join("").split("—").join("");
var input = $(this).val().split("-").join("").split("—").join("")
if (input.length > 0) {
input = input.match(new RegExp('.{1,4}', 'g')).join("—");
input = input.match(new RegExp('.{1,4}', 'g')).join("—")
}
$(this).val(input);
});
});
$(this).val(input)
})
})
$('form[name=form-quiz-start]').submit(function(event) {
var $form = $(this);
var data = $form.serialize();
var $form = $(this)
var data = $form.serialize()
$.ajax({
url: window.location.pathname,
@ -24,21 +24,21 @@ $('form[name=form-quiz-start]').submit(function(event) {
dataType: 'json',
success: function(response) {
var id = response.id
window.localStorage.setItem('id', id);
window.location.href = `/quiz/`;
window.localStorage.setItem('id', id)
window.location.href = `/quiz/`
},
error: function(response) {
error_response(response);
error_response(response)
}
});
})
event.preventDefault();
});
event.preventDefault()
})
function error_response(response) {
const $alert = $("#alert-box");
$alert.html('');
const $alert = $("#alert-box")
$alert.html('')
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
@ -47,18 +47,18 @@ function error_response(response) {
${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) {
var output = ''
for (var i = 0; i < response.responseJSON.error.length; i ++) {
for (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<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>
`;
$alert.html(output);
`
$alert.html(output)
}
}
}
@ -74,13 +74,13 @@ $('#dismiss-cookie-alert').click(function(event){
},
dataType: 'json',
success: function(response){
console.log(response);
console.log(response)
},
error: function(response){
console.log(response);
console.log(response)
}
})
event.preventDefault();
event.preventDefault()
})

View File

@ -1,8 +1,13 @@
from ..models import Dataset
from flask import current_app as app
from flask import flash, redirect
from flask.helpers import url_for
import json
from pathlib import Path
from random import shuffle
from functools import wraps
def load(filename:str):
data_dir = Path(app.config.get('DATA'))
@ -66,4 +71,14 @@ def get_tag_list(dataset:list):
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
return output
def check_dataset_exists(function):
@wraps(function)
def wrapper(*args, **kwargs):
datasets = Dataset.query.all()
if not datasets:
flash('There are no available question datasets. Please upload a question dataset first, or use the question editor to create a new dataset.', 'error')
return redirect(url_for('admin._questions'))
return function(*args, **kwargs)
return wrapper

View File

@ -57,5 +57,5 @@ def get_dataset_choices():
return dataset_choices
def send_errors_to_client(form):
errors = [*form.errors]
errors = [*form.errors.values()]
return jsonify({ 'error': errors}), 400

View File

View File

@ -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 `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.form-label-group input {
background-color: transparent;
border: none;
border-radius: 0%;
border-bottom: 2px solid #585858;
}
.form-label-group input:active, .form-label-group input:focus {
background-color: transparent;
}
.form-label-group input::-webkit-input-placeholder {
color: transparent;
}
.form-label-group input:-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-moz-placeholder {
color: transparent;
}
.form-label-group input::placeholder {
color: transparent;
}
.form-label-group input:not(:placeholder-shown) {
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
padding-bottom: calc(var(--input-padding-y) / 3);
}
.form-label-group input:not(:placeholder-shown) ~ label {
padding-top: calc(var(--input-padding-y) / 3);
padding-bottom: calc(var(--input-padding-y) / 3);
font-size: 12px;
color: #777;
}
.form-check {
margin-bottom: 2rem;
}
.checkbox input {
transform: scale(1.5);
margin-right: 1rem;
}
.signin-forgot-password {
font-size: 14pt;
}
.form-submission-button {
margin-bottom: 2rem;
}
.form-submission-button button, .form-submission-button a {
margin: 1rem;
vertical-align: middle;
}
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
margin: 0 2px;
}
table.dataTable {
border-collapse: collapse;
width: 100%;
}
.table-row {
vertical-align: middle;
}
.row-actions {
text-align: center;
white-space: nowrap;
}
.dataTables_wrapper .dt-buttons {
left: 50%;
transform: translateX(-50%);
float:none;
text-align:center;
}
.row-actions button, .row-actions a {
margin: 0px 5px;
}
#cookie-alert {
padding-right: 16px;
}
#dismiss-cookie-alert {
margin-top: 16px;
width: fit-content;
}
.alert-db-empty {
width: 100%;
max-width: 720px;
font-size: 14pt;
margin: 20px auto;
}
.form-date-input, .form-select-input {
position: relative;
margin: 2rem 0;
}
.form-date-input input,
.form-date-input label, .form-select-input select, .form-select-input label {
padding: var(--input-padding-y) var(--input-padding-x);
font-size: 16pt;
width: 100%;
background-color: transparent;
border: none;
border-bottom: 2px solid #585858;
}
.datepicker::-webkit-calendar-picker-indicator {
border: 1px;
border-color: gray;
border-radius: 10%;
}
.form-date-input label, .form-select-input label {
/* position: absolute; */
/* top: 0;
left: 0; */
display: block;
width: 100%;
margin-bottom: 0; /* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.button-icon {
font-size: 20px;
}
.form-upload {
margin: 2rem 0;
font-size: 14pt;
}
.result-action-buttons, .test-action {
margin: 5px auto;
width: fit-content;
}
.accordion-item {
background-color: unset;
}
/* Change Autocomplete styles in Chrome*/
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
}
/* Fallback for Edge
-------------------------------------------------- */
@supports (-ms-ime-align: auto) {
.form-label-group label {
display: none;
}
.form-label-group input::-ms-input-placeholder {
color: #777;
}
}
/* Fallback for IE
-------------------------------------------------- */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.form-label-group label {
display: none;
}
.form-label-group input:-ms-input-placeholder {
color: #777;
}
}

View File

@ -0,0 +1,30 @@
.info-panel {
display: none;
}
.control-panel {
margin-left: auto;
margin-right: 0;
width:fit-content;
}
#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;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,115 @@
// Menu Highlight Scripts
const menuItems = document.getElementsByClassName('nav-link')
for(let i = 0; i < menuItems.length; i++) {
if(menuItems[i].pathname == window.location.pathname) {
menuItems[i].classList.add('active')
}
}
const dropdownItems = document.getElementsByClassName('dropdown-item')
for(let i = 0; i< dropdownItems.length; i++) {
if(dropdownItems[i].pathname == window.location.pathname) {
dropdownItems[i].classList.add('active')
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
}
}
// General Post Method Form Processing Script
$('form.form-post').submit(function(event) {
var $form = $(this)
var data = $form.serialize()
var url = $(this).prop('action')
var rel_success = $(this).data('rel-success')
$.ajax({
url: url,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.redirect_to) {
window.location.href = response.redirect_to
}
else {
window.location.href = rel_success
}
},
error: function(response) {
error_response(response)
}
})
event.preventDefault()
})
function error_response(response) {
const $alert = $("#alert-box")
$alert.html('')
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
<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) {
var output = ''
for (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<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>
`
$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()
})

View File

@ -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 = `<strong>Question ${block['q_no'] + 1}.</strong> ${block['text']}`
obj.append(text)
question_body = document.createElement('div')
question_body.className ='question-body'
type = document.createElement('p')
type.innerHTML = `<strong>Question Type:</strong> ${block['q_type']}`
question_body.append(type)
options = document.createElement('p')
options.innerHTML = '<strong>Options:</strong>'
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 += ' <span class="badge rounded-pill bg-success">Correct</span>'
}
option_list.append(option)
}
options.append(option_list)
question_body.append(options)
tags = document.createElement('p')
tags.innerHTML = `<strong>Tags:</strong>`
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 = `<strong>Block ${i+1}.</strong> ${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 = `<strong>Question ${question['q_no'] + 1}.</strong> ${question['text']}`
block_question.append(text)
question_body = document.createElement('div')
question_body.className ='question-body'
type = document.createElement('p')
type.innerHTML = `<strong>Question Type:</strong> ${question['q_type']}`
question_body.append(type)
options = document.createElement('p')
options.innerHTML = '<strong>Options:</strong>'
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 += ' <span class="badge rounded-pill bg-success">Correct</span>'
}
option_list.append(option)
}
options.append(option_list)
question_body.append(options)
tags = document.createElement('p')
tags.innerHTML = `<strong>Tags:</strong>`
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)
}
})
})

View File

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/style.css') }}"
/>
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/view.css') }}"
/>
{% block style %}
{% endblock %}
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
{% include "view/components/og-meta.html" %}
</head>
<body class="bg-light">
{% block navbar %}
{% include "view/components/navbar.html" %}
{% endblock %}
<div class="container">
{% block top_alerts %}
{% include "view/components/server-alerts.html" %}
{% endblock %}
{% block content %}{% endblock %}
</div>
<footer class="container site-footer mt-5">
{% block footer %}
{% include "view/components/footer.html" %}
{% endblock %}
</footer>
<!-- JQuery, Popper, and Bootstrap js dependencies -->
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous">
</script>
<script>
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
</script>
<script
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
crossorigin="anonymous">
</script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
crossorigin="anonymous"
></script>
<!-- Custom js -->
<script type="text/javascript">
var csrf_token = "{{ csrf_token() }}";
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
}
}
});
</script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/script.js') }}"
></script>
{% block script %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1 @@
<div id="alert-box" tabindex="-1"></div>

View File

@ -0,0 +1,28 @@
{% extends "view/components/base.html" %}
{% block datatable_css %}
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
{% endblock %}
{% block datatable_scripts %}
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
{% endblock %}

View File

@ -0,0 +1,2 @@
<p>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 <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p>
<p>All questions in the test are &copy; The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>

View File

@ -0,0 +1,4 @@
{% extends "view/components/base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block top_alerts %}
{% endblock %}

View File

@ -0,0 +1,117 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar"
aria-controls="navbar"
aria-expanded="false"
aria-label="Toggle Navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbar">
<ul class="navbar-nav">
{% if not current_user.is_authenticated %}
<li class="nav-item" id="nav-login">
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item" id="nav-results">
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
</li>
<li class="nav-item dropdown" id="nav-tests">
<a
class="nav-link dropdown-toggle"
id="dropdown-tests"
role="button"
href="{{ url_for('admin._tests') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Exams
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-settings"
>
<li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-settings">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Settings
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-settings"
>
<li>
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
</li>
<li>
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
</li>
<li>
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-account">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin._update_user', id=current_user.id) }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Account
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-account"
>
<li>
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
</li>
<li>
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
</li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

@ -0,0 +1,18 @@
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:locale" content="en_UK" />
<meta property="og:type" content="website" />
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta name="twitter:creator" content="@viveksantayana" />
<meta name="twitter:site" content="@viveksantayana" />
<meta name="theme-color" content="#343a40" />
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">

View File

@ -0,0 +1,23 @@
<div class="navbar navbar-expand-sm navbar-light bg-light">
<div class="container-fluid">
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
</li>
</ul>
</div>
</div>
</div>

View File

@ -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" %}
<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 }}
<div class="d-flex justify-content-center w-100">
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
</div>
</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,116 @@
{% extends "view/components/base.html" %}
{% block style %}
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/view.css') }}"
/>
{% endblock %}
{% block content %}
<h1>View Questions</h1>
<div class="container">
<p class="lead">
This page lists all the questions in the selected dataset.
</p>
</div>
<div class="container control-panel">
<button class="btn btn-primary" aria-title="Information" title="Information"><i class="bi bi-info-circle-fill"></i></button>
</div>
<div class="container info-panel">
<h3>
Information
</h3>
<p>
Questions in the test are arranged in blocks. Blocks can be of two types: <strong>Blocks</strong> of multiple related questions, and <strong>Single Questions</strong> that are not part of a block.
You can add, remove, or edit both Blockss and Questions through this editor.
</p>
<p>
<strong>Blocks</strong> 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.
</p>
<p>
Blocks can contain any number of questions within them, but cannot contain nested blocks.
</p>
<p>
When you set up a block, you can also add <strong>header text</strong> that will be displayed with each question.
You can use this to provide common information for a scenario across a series of questions.
</p>
<p>
Questions come in three types:
<ul>
<li>
<strong>Yes/No</strong> for when there is only a yes or no option,
</li>
<li>
<strong>Multiple Choice</strong> for your regular multiple choice questions, and
</li>
<li>
<strong>Ordered List</strong> for multiple choice questions that will be displayed in the same order as listed here.
</li>
</ul>
</p>
<p>
Normally, multiple choice questions will have the order of the options randomised.
</p>
<p>
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.
</p>
<p>
Questions can also be categorised using <strong>tags</strong>.
</p>
<p class="lead">
Placeholder for Questions Remaining in a Block
</p>
<p>
In order to show how many questions are remaining inside a block, e.g. to say &lsquo;the next n questions are about a specific scenario&rsquo;, the app uses the placeholder <code>&lt;block_remaining_questions&gt;</code>.
</p>
</div>
<div class="container viewer-panel">
<h3>
Question Dataset
</h3>
<div class="container dataset-metadata">
<div class="input-group mb-3">
<span class="input-group-text">Dataset Name</span>
<span class="form-control">
{{ dataset.get_name() }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Author</span>
<span class="form-control">
{{ dataset.creator.get_username() }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Last Updated</span>
<span class="form-control">
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
</span>
</div>
{% if dataset.default %}
<div class="input-group mb-3">
<span class="input-group-text">
<input type="checkbox" aria-label="Default" class="dataset-default" checked disabled>
</span>
<span class="form-control">
Default Dataset
</select>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block script %}
<script>
const target = "{{ url_for('api._editor') }}"
const id = "{{ dataset.id }}"
</script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/view.js') }}"
></script>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "view/components/input-forms.html" %}
{% block content %}
<div class="form-container">
<form name="form-editor" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for(request.endpoint, **request.view_args) }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form">View Questions</h2>
{{ form.hidden_tag() }}
<div class="form-select-input">
{{ form.dataset(placeholder="Select Question Dataset") }}
{{ form.dataset.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-book-fill button-icon"></i>
View
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -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('/<string:id>/')
@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)

View File

@ -1 +0,0 @@
*

34
ref-test/install.py Executable file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env python
from main import app
from app.extensions import db
from app.tools.data import save
from app.tools.logs import write
from sqlalchemy_utils import create_database, database_exists
from cryptography.fernet import Fernet
from os import mkdir, path
from pathlib import Path
data = Path(app.config.get('DATA'))
database_uri = app.config.get('SQLALCHEMY_DATABASE_URI')
with app.app_context():
if not path.isdir(f'./{data}'): mkdir(f'./{data}')
if not path.isdir(f'./{data}/questions'): mkdir(f'./{data}/questions')
if not path.isfile(f'./{data}/.gitignore'):
with open(f'./{data}/.gitignore', 'w') as file: file.write(f'*')
if not path.isfile(f'./{data}/config.json'): save({}, 'config.json')
if not path.isdir(f'./{data}/logs'): mkdir(f'./{data}/logs')
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/system.log'): write('system.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
if not database_exists(database_uri):
create_database(database_uri)
write('system.log', 'No database found. Creating a new database.')
from app.models import *
db.create_all()
write('system.log', 'Creating database schema.')
if not path.isfile(f'./{data}/.encryption.key'):
write('system.log', 'No encryption key found. Generating new encryption key.')
with open(f'./{data}/.encryption.key', 'wb') as key_file:
key = Fernet.generate_key()
key_file.write(key)