80 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
c04c824585 Editor client javascript and css 2022-06-22 01:58:36 +01:00
8eb7fb6869 Cleaned unnecessary import 2022-06-22 01:58:17 +01:00
db88b84ecb Added editor home page form 2022-06-22 01:57:58 +01:00
13c587b7da Added Editor api views 2022-06-22 01:57:45 +01:00
2b2a6ddd25 Updated json structure validation
Only works with the data list
File parsed in the View layer
2022-06-22 01:57:03 +01:00
26a6b45d75 Added dataset name support 2022-06-22 01:56:13 +01:00
c6c62fc34c Changed the layer at which json files are parsed
Updated dataset database model
Updated create and edit function to use data list instead of file
2022-06-22 01:55:55 +01:00
6bbdb8fced Corrected timestamping 2022-06-22 01:54:53 +01:00
c633a474b5 Updated dataset edit button handlers 2022-06-22 01:54:40 +01:00
5af99d85b5 Added editor link to navbar 2022-06-22 01:54:00 +01:00
1e7124262e Added support for dataset names 2022-06-22 01:53:46 +01:00
2f509af1de Added redirect on login to previous page 2022-06-22 01:53:06 +01:00
3c8c1b5c16 Finished making editor console 2022-06-22 01:52:40 +01:00
3988559920 Cleaned up unused file 2022-06-22 01:47:07 +01:00
8988fee55d Merge branch 'editor' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test into editor 2022-06-21 02:50:09 +01:00
86d1522ca1 Rectified editor script 2022-06-21 02:47:09 +01:00
ed53b771ef Finished designing the editor console 2022-06-21 02:44:23 +01:00
bc3b811fc9 Finished designing the editor console 2022-06-21 02:44:23 +01:00
f314566591 Merge branch 'master' into editor 2022-06-20 13:55:39 +01:00
4b6dbd4441 Merge branch 'master' into editor 2022-06-20 13:55:39 +01:00
1ef34465c2 Debug install script 2022-06-20 12:53:40 +01:00
8b0ea1fec3 Make config production ready 2022-06-20 12:28:31 +01:00
39acebb3a6 Make config production ready 2022-06-20 12:28:31 +01:00
3025e83b66 Editor styling 2022-06-20 12:22:29 +01:00
a02a58a8db Editor styling 2022-06-20 12:22:29 +01:00
de6910b4bf Merge branch 'master' into editor 2022-06-20 12:16:25 +01:00
7bb93afacb Merge branch 'master' into editor 2022-06-20 12:16:25 +01:00
500beed4cc Merge branch 'master' into editor 2022-06-20 12:13:09 +01:00
d83999aa43 Merge branch 'master' into editor 2022-06-20 12:13:09 +01:00
fac3839ea3 Merge branch 'master' into editor 2022-06-19 13:25:02 +01:00
d8d5e92453 Merge branch 'master' into editor 2022-06-19 13:25:02 +01:00
a050a1eccf Merge branch 'master' into editor 2022-06-19 11:21:22 +01:00
8d91dd1d30 Merge branch 'master' into editor 2022-06-19 11:21:22 +01:00
43cc0a5652 Added detailed data validation 2022-06-19 10:48:17 +01:00
4ce6536e33 Added detailed data validation 2022-06-19 10:48:17 +01:00
1f60054d46 Edited base template to set up Editor scripts/css 2022-06-19 10:47:50 +01:00
33bc7993fa Edited base template to set up Editor scripts/css 2022-06-19 10:47:50 +01:00
418dfe7a70 Added templates and static files for editor 2022-06-18 09:53:36 +01:00
645f69440f Added templates and static files for editor 2022-06-18 09:53:36 +01:00
e1e279e939 Base editor template 2022-06-18 09:43:07 +01:00
c197f6cb76 Base editor template 2022-06-18 09:43:07 +01:00
7fe1afb348 Create editor files 2022-06-18 09:39:31 +01:00
bed186f6b5 Create editor files 2022-06-18 09:39:31 +01:00
74 changed files with 3518 additions and 593 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

@ -2,7 +2,12 @@
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
if ! [ -x "$(command -v docker compose)" ]; then
if ! [ -x "$(command -v docker)" ]; then
echo 'Error: docker is not installed.' >&2
exit 1
fi
if ! [ -x "$(command -v compose)" ]; then
echo 'Error: docker compose is not installed.' >&2
exit 1
fi
@ -41,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
@ -74,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 Production as Config
from .install import install_app
from .models import User
from .extensions import bootstrap, csrf, db, login_manager, mail
@ -31,7 +30,7 @@ def create_app():
def _check_cookie_consent():
if request.cookies.get('cookie_consent'):
return
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/' ] ]):
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/', '/admin/editor/static' ] ]):
return
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
@ -46,12 +45,14 @@ def create_app():
from .api.views import api
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)
install_app(app)
app.register_blueprint(editor, url_prefix='/admin/editor')
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,47 +80,58 @@ $('.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 filename = $(this).data('filename');
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 ) {
$.ajax({
url: `/admin/settings/questions/${action}/`,
type: 'POST',
data: JSON.stringify({'filename': filename}),
contentType: 'application/json',
success: function(response) {
window.location.reload();
},
error: function(response){
error_response(response);
},
});
};
event.preventDefault();
});
if (action == 'delete') {
$.ajax({
url: `/admin/settings/questions/${action}/`,
type: 'POST',
data: JSON.stringify({
'id': id,
'action': action,
}),
contentType: 'application/json',
success: function(response) {
window.location.reload()
},
error: function(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()
})
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(`
@ -129,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)
}
}
@ -158,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({
@ -181,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({
@ -197,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({
@ -222,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-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._home') }}">
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ next or url_for('admin._home') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form">Log In</h2>
{{ form.hidden_tag() }}

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,7 +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('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,7 +57,7 @@
<thead>
<tr>
<th>
Uploaded
Name
</th>
<th>
Exams
@ -68,7 +68,9 @@
{% for dataset in datasets %}
<tr>
<td>
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
<a href="{{ url_for('editor._editor_console', id=dataset.id) }}">
{{ dataset.get_name() }}
</a>
</td>
<td>
{{ dataset.tests|length }}

View File

@ -9,9 +9,12 @@
<tr>
<th>
</th>
<th data-priority="1">
Name
</th>
<th data-priority="2">
Uploaded
Updated
</th>
<th data-priority="3">
Author
@ -36,6 +39,9 @@
</div>
{% endif %}
</td>
<td>
{{ element.get_name() }}
</td>
<td>
{{ element.date.strftime('%d %b %Y %H:%M') }}
</td>
@ -47,23 +53,41 @@
</td>
<td class="row-actions">
<a
href="#"
class="btn btn-primary edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
data-filename="{{ element.filename }}"
data-action="default"
title="Make Default"
href="javascript:void(0)"
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="download"
title="Download Questions"
>
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
</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>
</button>
</a>
<a
href="#"
class="btn btn-danger edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
data-filename="{{ element.filename }}"
href="javascript:void(0)"
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="edit"
title="Edit Questions"
>
<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 %}
@ -72,13 +96,23 @@
{% else %}
<div class="alert alert-primary alert-db-empty">
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
There are no question datasets uploaded. Please use the panel below to upload a new question dataset.
There are no question datasets uploaded. Please use the panel below to upload a new question dataset or create a new dataset using the editor console.
</div>
{% endif %}
<div class="col text-center">
<button title="Create New" class="btn btn-md btn-primary btn-block create-new-dataset">
<i class="bi bi-cloud-plus-fill button-icon"></i>
Create New Dataset
</button>
</div>
<div class="form-container">
<form name="form-upload-questions" class="form-display" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="" enctype="multipart/form-data">
<h2 class="form-heading">Upload Question Dataset</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.name(class_="form-control", autofocus=true, placeholder="Enter Name of Dataset") }}
{{ form.name.label }}
</div>
<div class="form-upload">
{{ form.data_file() }}
</div>
@ -89,8 +123,8 @@
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button title="Create User" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-file-earmark-arrow-up-fill button-icon"></i>
<button title="Upload Dataset" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-cloud-arrow-up-fill button-icon"></i>
Upload Dataset
</button>
</div>
@ -106,10 +140,10 @@
$(document).ready(function() {
$('#question-datasets-table').DataTable({
'columnDefs': [
{'sortable': false, 'targets': [0,4]},
{'searchable': false, 'targets': [0,3,4]}
{'sortable': false, 'targets': [0,5]},
{'searchable': false, 'targets': [1,2,3]}
],
'order': [[1, 'desc'], [2, 'asc']],
'order': [[1, 'asc'], [2, 'desc'], [3, 'asc']],
'responsive': 'true',
'fixedHeader': 'true',
});

View File

@ -1 +0,0 @@
{% extends "admin/components/base.html" %}

View File

@ -2,15 +2,16 @@ 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 Blueprint, jsonify, render_template, redirect, request, session
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
admin = Blueprint(
@ -90,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():
@ -116,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)
@ -125,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()
@ -207,10 +209,13 @@ def _questions():
if form.validate_on_submit():
upload = form.data_file.data
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
if not validate_json(upload): return jsonify({'error': 'The data in the file is invalid.'}), 400
upload.stream.seek(0)
data = loads(upload.read())
if not validate_json(data=data): return jsonify({'error': 'The data in the file is invalid.'}), 400
new_dataset = Dataset()
new_dataset.set_name(request.form.get('name'))
success, message = new_dataset.create(
upload = upload,
data = data,
default = request.form.get('default')
)
if success: return jsonify({'success': message}), 200
@ -220,33 +225,39 @@ def _questions():
data = Dataset.query.all()
return render_template('/admin/settings/questions.html', form=form, data=data)
@admin.route('/settings/questions/edit/', methods=['POST'])
@admin.route('/settings/questions/delete/', methods=['POST'])
@login_required
def _edit_questions():
id = request.get_json()['id']
action = request.get_json()['action']
if action not in ['defailt', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
if not action == 'delete': return jsonify({'error': 'Invalid action.'}), 400
dataset = Dataset.query.filter_by(id=id).first()
if action == 'delete': success, message = dataset.delete()
elif action == 'default': success, message = dataset.make_default()
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
@admin.route('/settings/questions/download/<string:id>/')
@login_required
def _download(id:str):
dataset = Dataset.query.filter_by(id=id).first()
if not dataset: return abort(404)
data_path = path.abspath(dataset.get_file())
return send_file(data_path, as_attachment=True, attachment_filename=f'{dataset.get_name()}.json')
@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

@ -1,7 +1,9 @@
from ..models import Dataset, Entry
from ..models import Dataset, Entry, User
from ..tools.data import validate_json
from ..tools.test import evaluate_answers, generate_questions
from flask import Blueprint, jsonify, request
from flask import Blueprint, flash, jsonify, request, url_for
from flask_login import login_required
from datetime import datetime, timedelta
from json import loads
@ -26,7 +28,7 @@ def _fetch_questions():
time_adjustment = test.adjustments[user_code]
_time_limit += time_adjustment
end_delta = timedelta(minutes=_time_limit)
end_time = datetime.utcnow() + end_delta
end_time = datetime.now() + end_delta
else:
end_time = None
entry.start()
@ -63,4 +65,37 @@ def _submit_quiz():
'success': 'Your submission has been processed. Redirecting you to receive your results.',
'id': id
}), 200
@api.route('/editor/', methods=['POST'])
@login_required
def _editor(id:str=None):
request_data = request.get_json()
id = request_data['id']
dataset = Dataset.query.filter_by(id=id).first()
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
data_path = dataset.get_file()
if request_data['action'] == 'fetch':
with open(data_path, 'r') as data_file:
data = loads(data_file.read())
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
default = request_data['default']
creator = request_data['creator']
name = request_data['name']
data = request_data['data']
if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400
user = User.query.filter_by(id=creator).first()
dataset.set_name(name)
dataset.creator = user
success, message = dataset.update(data=data, default=default)
if not success: return jsonify({'error': message}), 400
return jsonify({'success': message}), 200
@api.route('/editor/new/', methods=['POST'])
@login_required
def _editor_new():
new_dataset = Dataset()
new_dataset.set_name('New Dataset')
success, message = new_dataset.create(data=[], default=False)
if not success: return jsonify({'error':message}), 400
flash(message, 'success')
return jsonify({'success': message, 'redirect_to': url_for('editor._editor_console', id=new_dataset.id)}), 200

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

View File

@ -0,0 +1,103 @@
.accordion-button {
color: inherit;
background-color: inherit;
display: block;
border: 1px solid rgb(0 0 0 / .3);
height: 60px;
}
.editor-controls {
width: fit-content;
display: block;
margin: 10px auto;
}
.editor-controls a {
margin: 10px 10px;
}
.editor-controls a i {
font-size: larger;
margin: 2px;
}
.option-controls, .block-controls {
width: fit-content;
display: block;
margin: 10px auto;
}
.option-controls a, .block-controls a {
margin: 0 10px;
z-index: 10;
}
.option-controls a i, .block-controls a i {
font-size: larger;
margin: 2px;
}
.accordion-button div {
margin: 0;
position: relative;
top: 50%;
transform: translate(0, -50%);
}
.accordion-button::after {
content: none;
}
.accordion-error {
background-color: #bb2d3b;
color: white;
}
.accordion-error:not(.collapsed) {
background-color: #bb2d3b;
color: white;
}
.panel-button {
padding: 6px;
margin: 0px 2px;
}
.panel-button i {
font-size: larger;
}
.editor-panel, .info-panel {
margin: 30pt auto;
}
.info-panel, .viewer-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;
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -0,0 +1,642 @@
// Variable Declarations
const $root = $('#editor-root')
const target = $root.data('target')
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 and Viewer Button Listener
$control_panel.find('button').click(function(event){
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()
})
// Control Button Listeners
$root.on('click', '.block-controls > a', function(event){
event.preventDefault()
var action = $(this).data('action')
var root_accordion = $(this).closest('div').siblings('.accordion')
if (action == 'add-question') {
var question = generate_single_question(root_container_id=`#${root_accordion.attr('id')}`)
$(question).appendTo(root_accordion).hide().fadeIn()
if (root_accordion.children().length > 1 ) {
root_accordion.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
} else {
root_accordion.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
}
renumber_blocks()
}
})
$root.on('click', '.panel-controls > a', function(event) {
event.preventDefault()
event.stopPropagation()
var action = $(this).data('action')
var element = $(this).closest('.accordion-item')
var root_container = $(this).closest('.accordion')
if (action == 'delete') {
element.fadeOut(function(){
$(this).remove()
renumber_blocks()
if (root_container.get(0) != $root.get(0) && root_container.children().length < 2 ) {
root_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
}
})
} else if (action == 'add-question') {
var question = generate_single_question(root_container_id=`#${root_container.attr('id')}`)
$(question).insertBefore(element).hide().fadeIn()
if (root_container.get(0) != $root.get(0) && root_container.children().length > 1 ) {
root_container.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
}
} else if (action == 'add-block') {
var block = generate_block(root_container_id=`#${root_container.attr('id')}`)
$(block).insertBefore(element).hide().fadeIn()
var block_container = $(`#element${element_index-1}`).children().find('.accordion')
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
var question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
block_container.append(question)
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
}
renumber_blocks()
})
$root.on('click', '.option-controls > a', function(event) {
event.preventDefault()
var action = $(this).data('action')
var options = $(this).closest('div.option-controls').siblings('.options')
var length = options.children().length
var correct = $(this).closest('div.option-controls').siblings().find('.question-correct')
if (action == 'delete') {
if (length > 2) {
options.children().last().fadeOut(function(){
$(this).remove()
length = options.children().length
if (length <= 2) {
options.siblings('div.option-controls').children('a[data-action="delete"]').addClass('disabled')
} else {
options.siblings('div.option-controls').children('a[data-action="delete"]').removeClass('disabled')
}
})
correct.children().last().fadeOut(function(){
$(this).remove()
})
}
} else {
var opt = `
<div class="input-group mb-3">
<span class="input-group-text">${length}</span>
<input type="text" class="form-control" value="Option ${length}">
</div>
`
$(opt).appendTo(options).hide().fadeIn()
var cor = `<option value="${length}">${length}</option>`
correct.append(cor)
}
length = options.children().length
if (length <= 2) {
$(this).closest('div.option-controls').children('a[data-action="delete"]').addClass('disabled')
} else {
$(this).closest('div.option-controls').children('a[data-action="delete"]').removeClass('disabled')
}
})
$('.editor-controls > a').click(function(event){
event.preventDefault()
var action = $(this).data('action')
var root_accordion = $(this).closest('div').siblings('.accordion')
if (action == 'add-question') {
var obj = generate_single_question(root_container_id=`#${root_accordion.attr('id')}`)
$(obj).appendTo($root).hide().fadeIn()
} else if (action == 'add-block') {
var obj = generate_block(root_container_id=`#${root_accordion.attr('id')}`)
$(obj).appendTo($root).hide().fadeIn()
var block_container = $(`#element${element_index-1}`).children().find('.accordion')
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
var question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
block_container.append(question)
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
} else if (action == 'discard') {
window.location.href = '/admin/settings/questions/'
} else if (action == 'delete') {
$.ajax({
url: '/admin/settings/questions/delete/',
type: 'POST',
data: JSON.stringify({
'id': id,
'action': action
}),
contentType: 'application/json',
success: function(response) {
window.location.href = '/admin/settings/questions/'
},
error: function(response) {
error_response(response)
}
})
} else if (action == 'save') {
var input = parse_input()
var def = $('.dataset-default').is(':checked')
var name = $('.dataset-name').val()
var creator = $('.dataset-creator').val()
console.log([def, name, creator])
$.ajax({
url: target,
type: 'POST',
data: JSON.stringify({
'id': id,
'action': 'upload',
'data': input,
'default': def,
'name': name,
'creator': creator
}),
contentType: 'application/json',
success: function(response) {
window.location.href = '/admin/settings/questions/'
},
error: function(response) {
error_response(response)
}
})
}
renumber_blocks()
})
// Question Type Select Menu Listener
$root.on('change', '.form-select.question-type', function(event) {
event.preventDefault()
var type = $(this).val()
var options = $(this).closest('div.input-group').siblings('.options')
var option_controls = $(this).closest('div.input-group').siblings('.option-controls')
var correct = $(this).closest('div.input-group').siblings().find('.question-correct')
if (type == 'Yes/No') {
options.empty()
correct.empty()
var opt = `
<div class="input-group mb-3">
<span class="input-group-text">0</span>
<input type="text" class="form-control" value="Yes" disabled>
</div>
<div class="input-group mb-3">
<span class="input-group-text">1</span>
<input type="text" class="form-control" value="No" disabled>
</div>
`
$(opt).appendTo(options).hide().fadeIn()
option_controls.children('a').addClass('disabled')
var cor = `
<option value ="0" default>0</option>
<option value="1">1</option>
`
correct.append(cor)
} else {
option_controls.children('a').removeClass('disabled')
options.find('input').removeAttr('disabled')
if (options.children().length <= 2 ){
option_controls.children('a[data-action="delete"]').addClass('disabled')
}
}
})
// Data and Rendering Functions
function renumber_blocks () {
$( ".block-number" ).each(function(index) {
$( this ).text($( this ).closest('.accordion-item').index() + 1)
})
}
function parse_input() {
var data = []
var element = {}
var question = {}
var block_container
var q_no = 0
$root.children().each(function(index) {
element = {}
if ($(this).data('type') == 'block') {
element['type'] = 'block'
element['question_header'] = $(this).find('.block-header-text').val()
element['questions'] = []
block_container = $(this).children().find('.accordion')
block_container.children().each(function(index) {
question = {}
question['q_no'] = q_no
question['text'] = $(this).find('.question-text').val()
question['q_type'] = $(this).find('.question-type').val()
question['correct'] = parseInt($(this).find('.question-correct').val())
question['options'] = []
$(this).find('.options').find('input').each(function(index) {
question['options'].push($(this).val())
})
question['tags'] = $(this).find('.question-tags').val().split('\r\n')
element['questions'].push(question)
q_no ++
})
} else if ( $(this).data('type') == 'question') {
element['type'] = 'question'
element['q_no'] = q_no
element['text'] = $(this).find('.question-text').val()
element['q_type'] = $(this).find('.question-type').val()
element['correct'] = parseInt($(this).find('.question-correct').val())
element['options'] = []
$(this).find('.options').find('input').each(function(index) {
element['options'].push($(this).val())
})
element['tags'] = $(this).find('.question-tags').val().split('\r\n')
q_no ++
}
data.push(element)
})
return data
}
function parse_data(data) {
var block, obj, new_block, block_container, question, _question, new_question, options, correct, opt, tags
for (let c = 0; c < data.length; c++) {
block = data[c]
if (block['type'] == 'block') {
obj = generate_block(root_container_id=`#${$root.attr('id')}`)
$root.append(obj)
new_block = $(`#element${element_index-1}`)
new_block.find('.block-header-text').val(block['question_header']).trigger('change')
block_container = $(`#element${element_index-1}`).children().find('.accordion')
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
for (let _c = 0; _c < block['questions'].length; _c ++) {
question = block['questions'][_c]
_question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
block_container.append(_question)
if (block_container.children().length <= 1) {
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
} else {
block_container.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
}
new_question = $(`#element${element_index-1}`)
new_question.find('.question-text').val(question['text']).trigger('change')
new_question.find('.question-type').val(question['q_type']).trigger('change')
correct = new_question.find('.question-correct')
if (question['q_type'] != 'Yes/No') {
options = new_question.find('.options')
options.empty()
correct.empty()
for ( var __c = 0; __c < question['options'].length; __c++) {
option = question['options'][__c]
opt = `
<div class="input-group mb-3">
<span class="input-group-text">${__c}</span>
<input type="text" class="form-control" value="${option}">
</div>
`
options.append(opt)
correct.append(`<option value="${__c}">${__c}</option>`)
}
}
correct.val(String(question['correct']))
tags = question['tags'].join('\r\n')
new_question.find('.question-tags').val(tags)
}
} else {
question = block
obj = generate_single_question(root_container_id=`#${$root.attr('id')}`)
$root.append(obj)
new_question = $(`#element${element_index-1}`)
new_question.find('.question-text').val(question['text']).trigger('change')
new_question.find('.question-type').val(question['q_type']).trigger('change')
correct = new_question.find('.question-correct')
if (question['q_type'] != 'Yes/No') {
options = new_question.find('.options')
options.empty()
correct.empty()
for ( var _c = 0; _c < question['options'].length; _c++) {
option = question['options'][_c]
opt = `
<div class="input-group mb-3">
<span class="input-group-text">${_c}</span>
<input type="text" class="form-control" value="${option}">
</div>
`
options.append(opt)
correct.append(`<option value="${_c}">${_c}</option>`)
}
}
correct.val(String(question['correct']))
tags = question['tags'].join('\r\n')
new_question.find('.question-tags').val(tags)
}
}
renumber_blocks()
}
// Content Generator Functions
function generate_single_question(root_container_id) {
if (root_container_id == `#${$root.attr('id')}`) {
var block_button = `
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-block" title="Add Block Above" aria-title="Add Block Above" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-folder-plus"></i>
</a>
`
} else {
var block_button = ''
}
var question = `
<div class="accordion-item" id="element${element_index}" data-type="question">
<h2 class="accordion-header" id="element${element_index}-header">
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#element${element_index}-content" aria-expanded="true" aria-controls="element${element_index}-content">
<div class="float-start">
<div class="accordion-caption">
<span class="block-number"></span>.
Question
</div>
</div>
<div class="panel-controls float-end">
<a href="javascript:void(0)" class="btn btn-success move-handle" data-action="move-question" title="Move Question" aria-title="Move Question" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-arrows-move"></i>
</a>
${block_button}
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-question" title="Add Question Above" aria-title="Add Question Above" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-file-plus-fill"></i>
</a>
<a href="javascript:void(0)" class="btn btn-danger" data-action="delete" title="Delete Block" aria-title="Delete Block" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-trash-fill"></i>
</a>
</div>
</div>
</h2>
<div id="element${element_index}-content" class="accordion-collapse collapse" aria-labelledby="element${element_index}-header" data-bs-parent="${root_container_id}">
<div class="accordion-body">
<div class="input-group mb-3">
<span class="input-group-text">Question</span>
<textarea type="text" class="form-control question-text">Enter question here.</textarea>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Question Type</span>
<select class="form-select question-type">
<option value ="Multiple Choice" default>Multiple Choice</option>
<option value="Yes/No">Yes/No</option>
<option value="List">Ordered List</option>
</select>
</div>
<label class="form-label">Options</label>
<ul class="options">
<div class="input-group mb-3">
<span class="input-group-text">0</span>
<input type="text" class="form-control" value="Option 0">
</div>
<div class="input-group mb-3">
<span class="input-group-text">1</span>
<input type="text" class="form-control" value="Option 1">
</div>
</ul>
<div class="option-controls">
<a href="javascript:void(0)" class="btn btn-danger disabled" data-action="delete" title="Delete Question" aria-title="Delete Question">
<i class="bi bi-patch-minus-fill"></i>
Delete
</a>
<a href="javascript:void(0)" class="btn btn-success" data-action="add" title="Add Question" aria-title="Add Question">
<i class="bi bi-patch-plus-fill"></i>
Add
</a>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Correct</span>
<select class="form-select question-correct">
<option value ="0" default>0</option>
<option value="1">1</option>
</select>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Tags</span>
<textarea type="text" class="form-control question-tags"></textarea>
</div>
</div>
</div>
</div>
`
element_index ++
return question
}
function generate_block(root_container_id) {
var block = `
<div class="accordion-item" id="element${element_index}" data-type="block">
<h2 class="accordion-header" id="element${element_index}-header">
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#element${element_index}-content" aria-expanded="true" aria-controls="element${element_index}-content">
<div class="float-start">
<div class="accordion-caption">
<span class="block-number"></span>.
Block
</div>
</div>
<div class="panel-controls float-end">
<a href="javascript:void(0)" class="btn btn-success move-handle" data-action="move-question" title="Move Question" aria-title="Move Question" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-arrows-move"></i>
</a>
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-block" title="Add Block Above" aria-title="Add Block Above" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-folder-plus"></i>
</a>
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-question" title="Add Question Above" aria-title="Add Question Above" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-file-plus-fill"></i>
</a>
<a href="javascript:void(0)" class="btn btn-danger" data-action="delete" title="Delete Block" aria-title="Delete Block" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-trash-fill"></i>
</a>
</div>
</div>
</h2>
<div id="element${element_index}-content" class="accordion-collapse collapse" aria-labelledby="element${element_index}-header" data-bs-parent="${root_container_id}">
<div class="accordion-body">
<div class="input-group mb-3">
<span class="input-group-text">Block Header</span>
<textarea type="text" class="form-control block-header-text">Enter the header text for this block of questions.</textarea>
</div>
<div class="accordion" id="element${element_index}-questions">
</div>
<div class="block-controls">
<a href="javascript:void(0)" class="btn btn-success" data-action="add-question" title="Add Question" aria-title="Add Question">
<i class="bi bi-file-plus-fill"></i>
Add Question
</a>
</div>
</div>
</div>
</div>
`
element_index ++
return block
}
// 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)
}
})
})
// 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)
}
}

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,81 @@
<!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') }}"
/>
{% block style %}
{% endblock %}
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
{% include "editor/components/og-meta.html" %}
</head>
<body class="bg-light">
{% block navbar %}
{% include "editor/components/navbar.html" %}
{% endblock %}
<div class="container">
{% block top_alerts %}
{% include "editor/components/server-alerts.html" %}
{% endblock %}
{% block content %}{% endblock %}
</div>
<footer class="container site-footer mt-5">
{% block footer %}
{% include "editor/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/sortablejs@latest/Sortable.min.js"></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 "editor/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 "admin/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,150 @@
{% extends "editor/components/base.html" %}
{% block style %}
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/editor.css') }}"
/>
{% endblock %}
{% block content %}
<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. 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="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>
About the Editor Console
</h3>
<p>
This console will allow you to edit the question data for the RefTest App.
All of the questions will be visually displayed as blocks on the screen that you can minimise, expand, and rearrange.
</p>
<p>
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><strong>Do not use language that will assume the flow of questions, such as saying &lsquo;the previous question&rsquo;, or &lsquo;the next question&rsquo;, etc. because of randomisation.</strong></p>
<p>
Each option will be referenced by an <strong>index number</strong>.
Make sure to select which index number represents the <strong>correct option</strong>.
</p>
<p>
You will also be able to define <strong>tags</strong> for each question.
Separate multiple tags in <strong>new lines</strong>.
Make sure to keep the spelling, capitalisation, and punctuation for tags consistent.
</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;, 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 Questions
</h3>
<div class="container dataset-metadata">
<div class="input-group mb-3">
<span class="input-group-text">Dataset Name</span>
<input type="text" class="form-control dataset-name" value="{{ dataset.get_name() }}">
</div>
<div class="input-group mb-3">
<span class="input-group-text">Author</span>
<select class="form-select dataset-creator">
{% for user in users %}
<option value="{{ user.id }}" {{default if dataset.user == user else None }}>{{ user.get_username() }}</option>
{% endfor %}
</select>
</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>
<div class="input-group mb-3">
<span class="input-group-text">
<input type="checkbox" aria-label="Default" class="dataset-default" {% if dataset.default %}checked{% endif %}>
</span>
<span class="form-control">
Make Dataset the Default
</select>
</div>
</div>
<div class="accordion" id="editor-root" data-target="{{ url_for('api._editor') }}" data-id="{{ dataset.id }}">
</div>
{% include "editor/components/client-alerts.html" %}
<div class="editor-controls container">
<a href="javascript:void(0);" class="btn btn-primary" data-action="add-block" title="Add Block" aria-title="Add Block">
<i class="bi bi-folder-plus"></i>
Add Block
</a>
<a href="javascript:void(0);" class="btn btn-primary" data-action="add-question" title="Add Question" aria-title="Add Question">
<i class="bi bi-file-plus-fill"></i>
Add Question
</a>
</div>
<div class="editor-controls container">
<a href="javascript:void(0);" class="btn btn-warning" data-action="discard" title="Discard Changes" aria-title="Discard Changes">
<i class="bi bi-x-circle-fill"></i>
Discard Changes
</a>
<a href="javascript:void(0);" class="btn btn-danger {% if datasets <=1 or dataset.default or dataset.tests|length > 0 %}disabled{% endif %}" data-action="delete" title="Delete" aria-title="Delete">
<i class="bi bi-trash-fill"></i>
Delete
</a>
<a href="javascript:void(0);" class="btn btn-success" data-action="save" title="Save" aria-title="Save">
<i class="bi bi-cloud-arrow-up-fill"></i>
Save Changes
</a>
</div>
</div>
{% endblock %}
{% block script %}
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/editor.js') }}"
></script>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "editor/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">Edit 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-pencil-fill button-icon"></i>
Edit
</button>
<button title="New" class="btn btn-md btn-primary create-new-dataset">
<i class="bi bi-cloud-plus-fill button-icon"></i>
New
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,40 @@
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
editor = Blueprint(
name='editor',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
@editor.route('/', methods=['GET','POST'])
@login_required
def _editor():
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('editor._editor_console', id=id)}),200
return send_errors_to_client(form=form)
form.process()
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()
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('/editor/console.html', dataset=dataset, datasets=datasets, users=users)

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,14 +49,18 @@ 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')
class UploadData(FlaskForm):
name = StringField('Name', validators=[InputRequired()])
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
default = BooleanField('Make Default', render_kw={'checked': True})
class AddTimeAdjustment(FlaskForm):
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
class EditDataset(FlaskForm):
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

@ -1,4 +1,5 @@
from ..extensions import db
from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write
from flask import flash
@ -7,7 +8,7 @@ from flask_login import current_user
from werkzeug.utils import secure_filename
from datetime import datetime
from json import dump, loads
from json import dump
from os import path, remove
from pathlib import Path
from uuid import uuid4
@ -15,13 +16,16 @@ from uuid import uuid4
class Dataset(db.Model):
id = db.Column(db.String(36), primary_key=True)
name = db.Column(db.String(128), nullable=False)
tests = db.relationship('Test', backref='dataset')
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
date = db.Column(db.DateTime, nullable=False)
default = db.Column(db.Boolean, default=False, nullable=True)
accessed = db.Column(db.DateTime, nullable=True)
locked = db.Column(db.Boolean, default=False, nullable=True)
def __repr__(self):
return f'<Dataset {self.id}> was added.'
return f'<Dataset {self.id}>.'
@property
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
@ -29,6 +33,14 @@ class Dataset(db.Model):
generate_id.setter
def generate_id(self): self.id = uuid4().hex
@property
def set_name(self): raise AttributeError('set_name is not a readable attribute.')
set_name.setter
def set_name(self, name:str): self.name = encrypt(name)
def get_name(self): return decrypt(self.name)
def make_default(self):
for dataset in Dataset.query.all():
dataset.default = False
@ -43,7 +55,7 @@ class Dataset(db.Model):
message = 'Cannot delete the default dataset.'
flash(message, 'error')
return False, message
if Dataset.query.all().count() == 1:
if Dataset.query.count() == 1:
message = 'Cannot delete the only dataset.'
flash(message, 'error')
return False, message
@ -56,23 +68,19 @@ class Dataset(db.Model):
db.session.commit()
return True, 'Dataset deleted.'
def create(self, upload, default:bool=False):
def create(self, data:list, default:bool=False):
self.generate_id()
timestamp = datetime.now()
filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename)
upload.stream.seek(0)
questions = loads(upload.read())
file_path = self.get_file()
with open(file_path, 'w') as file:
dump(questions, file, indent=2)
dump(data, file, indent=2)
self.date = timestamp
self.creator = current_user
if default: self.make_default()
write('system.log', f'New dataset {self.id} added by {current_user.get_username()}.')
write('system.log', f'New dataset {self.get_name()} added by {current_user.get_username()}.')
db.session.add(self)
db.session.commit()
return True, 'Dataset uploaded.'
return True, 'Dataset created.'
def check_file(self):
filename = secure_filename('.'.join([self.id,'json']))
@ -85,4 +93,16 @@ class Dataset(db.Model):
filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename)
return file_path
return file_path
def update(self, data:list=None, default:bool=False):
self.date = datetime.now()
if default: self.make_default()
file_path = self.get_file()
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.get_name()} successfully edited.', 'success')
db.session.add(self)
db.session.commit()
return True, 'Dataset successfully edited.'

View File

@ -1,5 +1,4 @@
from ..extensions import db
from ..tools.encryption import decrypt, encrypt
from ..tools.forms import JsonEncodedDict
from ..tools.logs import write

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'))
@ -18,12 +23,10 @@ def check_is_json(file):
if not '.' in file.filename or not file.filename.rsplit('.',1)[-1] == 'json': return False
return True
def validate_json(file):
file.stream.seek(0)
data = json.loads(file.read())
def validate_json(data):
if not isinstance(data, list): return False
for block in data:
block_type = block.pop('type', None)
block_type = block.get('type', None)
if block_type not in ['block', 'question']: return False
if block_type == 'question':
if not all (key in block for key in ['q_no', 'text', 'options', 'correct', 'q_type', 'tags']): return False
@ -68,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

@ -50,12 +50,12 @@ def get_dataset_choices():
datasets = Dataset.query.all()
dataset_choices = []
for dataset in datasets:
label = dataset.date.strftime('%Y%m%d%H%M%S')
label = dataset.get_name()
label = f'{label} (Default)' if dataset.default else label
choice = (dataset.id, label)
dataset_choices.append(choice)
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

@ -22,7 +22,7 @@ def _cookie_consent():
value='true',
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else None,
path = '/',
expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else None,
expires = datetime.now() + timedelta(days=14) if request.cookies.get('remember') else None,
domain = f'{app.config.get("SERVER_NAME")}',
secure = True
)

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)