Compare commits
73 Commits
Author | SHA1 | Date | |
---|---|---|---|
294f1e42f7 | |||
070ce19fcc | |||
615e59fc6d | |||
68314a4ed2 | |||
b90761fd2c | |||
af03193217 | |||
730a75c44d | |||
70883db5ad | |||
7cefb487da | |||
2e1b01ec9b | |||
a7a5a03991 | |||
b36c6bfd18 | |||
a613b0006b | |||
d4db8692e7 | |||
37ad36da31 | |||
d140f93d25 | |||
26a6248a61 | |||
9f8ea16974 | |||
bc5ec44145 | |||
ff5b19fa0b | |||
6c50be49c6 | |||
8bfe028e2c | |||
519394a656 | |||
9e1c9caec6 | |||
ea850c9ae2 | |||
591b868920 | |||
91dc93758a | |||
5d27baee08 | |||
1254cf3698 | |||
efab086057 | |||
06db47c597 | |||
c04c824585 | |||
8eb7fb6869 | |||
db88b84ecb | |||
13c587b7da | |||
2b2a6ddd25 | |||
26a6b45d75 | |||
c6c62fc34c | |||
6bbdb8fced | |||
c633a474b5 | |||
5af99d85b5 | |||
1e7124262e | |||
2f509af1de | |||
3c8c1b5c16 | |||
3988559920 | |||
8988fee55d | |||
86d1522ca1 | |||
ed53b771ef | |||
bc3b811fc9 | |||
f314566591 | |||
4b6dbd4441 | |||
8b0ea1fec3 | |||
39acebb3a6 | |||
3025e83b66 | |||
a02a58a8db | |||
de6910b4bf | |||
7bb93afacb | |||
500beed4cc | |||
d83999aa43 | |||
fac3839ea3 | |||
d8d5e92453 | |||
a050a1eccf | |||
8d91dd1d30 | |||
43cc0a5652 | |||
4ce6536e33 | |||
1f60054d46 | |||
33bc7993fa | |||
418dfe7a70 | |||
645f69440f | |||
e1e279e939 | |||
c197f6cb76 | |||
7fe1afb348 | |||
bed186f6b5 |
@ -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
5
.gitignore
vendored
@ -149,4 +149,7 @@ ref-test/testing.py
|
||||
database/data/
|
||||
|
||||
# Ignore Encryption Keyfile
|
||||
.encryption.key
|
||||
.encryption.key
|
||||
|
||||
# Ignore Data Dir
|
||||
**/data/*
|
56
README.md
56
README.md
@ -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```
|
||||
|
@ -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
|
||||
|
@ -46,8 +46,6 @@ if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
|
||||
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
|
||||
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
|
||||
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||
echo
|
||||
fi
|
||||
|
||||
@ -79,7 +77,7 @@ esac
|
||||
if [ $staging != "0" ]; then staging_arg="--staging"; fi
|
||||
|
||||
docker compose run --rm --entrypoint "\
|
||||
certbot certonly --webroot -w /var/www/html \
|
||||
certbot certonly --non-interactive --webroot -w /var/www/html \
|
||||
$staging_arg \
|
||||
$email_arg \
|
||||
$domain_args \
|
||||
|
@ -29,6 +29,11 @@ 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 / {
|
||||
include /etc/nginx/conf.d/proxy_headers.conf;
|
||||
proxy_pass http://reftest;
|
||||
|
@ -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" ]
|
@ -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,12 @@ def create_app():
|
||||
from .api.views import api
|
||||
from .quiz.views import quiz
|
||||
from .views import views
|
||||
from .editor.views import editor
|
||||
|
||||
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')
|
||||
|
||||
return app
|
@ -96,23 +96,32 @@ $('.test-action').click(function(event) {
|
||||
// Edit Dataset Button Handlers
|
||||
$('.edit-question-dataset').click(function(event) {
|
||||
|
||||
var filename = $(this).data('filename');
|
||||
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);
|
||||
},
|
||||
});
|
||||
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 == 'download') {
|
||||
window.location.href = `/admin/settings/questions/download/${id}/`
|
||||
}
|
||||
};
|
||||
event.preventDefault();
|
||||
});
|
||||
|
@ -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() }}
|
||||
|
@ -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() }}
|
||||
|
@ -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>
|
||||
|
@ -79,6 +79,9 @@
|
||||
<li>
|
||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 }}
|
||||
|
@ -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,18 +53,27 @@
|
||||
</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 Dataset"
|
||||
>
|
||||
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
|
||||
</button>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-primary edit-question-dataset"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="edit"
|
||||
title="Edit Dataset"
|
||||
>
|
||||
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||
</button>
|
||||
<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-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="delete"
|
||||
title="Delete Dataset"
|
||||
>
|
||||
@ -72,13 +87,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 +114,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 +131,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',
|
||||
});
|
||||
|
@ -1 +0,0 @@
|
||||
{% extends "admin/components/base.html" %}
|
@ -5,12 +5,13 @@ from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_
|
||||
from ..tools.data import 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,18 +225,25 @@ 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
|
||||
@ -247,6 +259,8 @@ def _tests(filter:str=None):
|
||||
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'
|
||||
|
@ -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
|
@ -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
|
||||
|
0
ref-test/app/editor/__init__.py
Normal file
0
ref-test/app/editor/__init__.py
Normal file
87
ref-test/app/editor/static/css/editor.css
Normal file
87
ref-test/app/editor/static/css/editor.css
Normal file
@ -0,0 +1,87 @@
|
||||
.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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
width:fit-content;
|
||||
}
|
||||
|
||||
#alert-box {
|
||||
margin: 30px auto;
|
||||
max-width: 460px;
|
||||
}
|
260
ref-test/app/editor/static/css/style.css
Normal file
260
ref-test/app/editor/static/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
BIN
ref-test/app/editor/static/favicon.ico
Normal file
BIN
ref-test/app/editor/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
ref-test/app/editor/static/favicon.png
Normal file
BIN
ref-test/app/editor/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
499
ref-test/app/editor/static/js/editor.js
Normal file
499
ref-test/app/editor/static/js/editor.js
Normal file
@ -0,0 +1,499 @@
|
||||
// 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 $editor_panel = $('.editor-panel')
|
||||
|
||||
var element_index = 0
|
||||
|
||||
// Initialise Sortable and trigger renumbering on end of drag
|
||||
Sortable.create($root.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
|
||||
// Info Button Listener
|
||||
$control_panel.find('button').click(function(event){
|
||||
if ($info_panel.is(":hidden")) {
|
||||
$editor_panel.hide()
|
||||
$info_panel.fadeIn()
|
||||
$(this).addClass('active')
|
||||
} else {
|
||||
$info_panel.hide()
|
||||
$editor_panel.fadeIn()
|
||||
$(this).removeClass('active')
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
2
ref-test/app/editor/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/app/editor/static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
115
ref-test/app/editor/static/js/script.js
Normal file
115
ref-test/app/editor/static/js/script.js
Normal 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 (var 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()
|
||||
})
|
81
ref-test/app/editor/templates/editor/components/base.html
Normal file
81
ref-test/app/editor/templates/editor/components/base.html
Normal 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>
|
@ -0,0 +1 @@
|
||||
<div id="alert-box" tabindex="-1"></div>
|
@ -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 %}
|
@ -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’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
||||
{% extends "admin/components/base.html" %}
|
||||
{% import "bootstrap/wtf.html" as wtf %}
|
||||
{% block top_alerts %}
|
||||
{% endblock %}
|
114
ref-test/app/editor/templates/editor/components/navbar.html
Normal file
114
ref-test/app/editor/templates/editor/components/navbar.html
Normal file
@ -0,0 +1,114 @@
|
||||
<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">Question Datasets</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</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>
|
18
ref-test/app/editor/templates/editor/components/og-meta.html
Normal file
18
ref-test/app/editor/templates/editor/components/og-meta.html
Normal 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') }}">
|
@ -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>
|
@ -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 %}
|
147
ref-test/app/editor/templates/editor/console.html
Normal file
147
ref-test/app/editor/templates/editor/console.html
Normal file
@ -0,0 +1,147 @@
|
||||
{% 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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="container control-panel">
|
||||
<button class="btn btn-primary" aria-title="Infrmation" title="Information"><i class="bi bi-info-circle-fill"></i></button>
|
||||
</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 ‘the previous question’, or ‘the next question’, 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 ‘the next n questions are about a specific scenario’, use the placeholder <code><block_remaining_questions></code>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="container editor-panel">
|
||||
<h3>
|
||||
Edit Dataset
|
||||
</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') }}
|
||||
</select>
|
||||
</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 %}
|
31
ref-test/app/editor/templates/editor/index.html
Normal file
31
ref-test/app/editor/templates/editor/index.html
Normal 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">Dataset Editor</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 %}
|
38
ref-test/app/editor/views.py
Normal file
38
ref-test/app/editor/views.py
Normal file
@ -0,0 +1,38 @@
|
||||
from ..forms.admin import EditDataset
|
||||
from ..models import Dataset, User
|
||||
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
||||
|
||||
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>/')
|
||||
@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)
|
@ -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')
|
@ -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)
|
@ -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.'
|
@ -1,5 +1,4 @@
|
||||
from ..extensions import db
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.forms import JsonEncodedDict
|
||||
from ..tools.logs import write
|
||||
|
||||
|
@ -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 '<same as old>'}</strong></p>
|
||||
<p>You can update your email address and password by logging in to the admin console using the following URL:</p>
|
||||
|
@ -146,7 +146,7 @@ $("#btn-start-quiz").click(function(event){
|
||||
data: JSON.stringify({'id': id}),
|
||||
contentType: "application/json",
|
||||
success: function(response) {
|
||||
$(this).fadeOut();
|
||||
$("#btn-start-quiz").fadeOut();
|
||||
$(".btn-quiz-return").fadeIn();
|
||||
$(".quiz-console").fadeIn();
|
||||
$("#quiz-settings").fadeOut();
|
||||
|
@ -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?
|
||||
|
@ -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%}
|
||||
|
||||
|
@ -18,12 +18,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
|
||||
|
@ -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
|
@ -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
|
||||
)
|
||||
|
1
ref-test/data/.gitignore
vendored
1
ref-test/data/.gitignore
vendored
@ -1 +0,0 @@
|
||||
*
|
34
ref-test/install.py
Executable file
34
ref-test/install.py
Executable 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)
|
Reference in New Issue
Block a user