Compare commits
66 Commits
3025e83b66
...
v0.3.1
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 | |||
1ef34465c2 | |||
8b0ea1fec3 | |||
39acebb3a6 | |||
d9962f18ed | |||
d8044a7c76 | |||
a02a58a8db | |||
7bb93afacb | |||
d83999aa43 | |||
d8d5e92453 | |||
8d91dd1d30 | |||
4ce6536e33 | |||
33bc7993fa | |||
645f69440f | |||
c197f6cb76 | |||
bed186f6b5 |
@ -1,5 +1,7 @@
|
|||||||
SERVER_NAME= # URL where this will be hosted.
|
SERVER_NAME= # URL where this will be hosted.
|
||||||
|
|
||||||
|
TZ=Europe/London # Time Zone
|
||||||
|
|
||||||
## Flask Configuration
|
## Flask Configuration
|
||||||
SECRET_KEY= # Long, secure, secret string.
|
SECRET_KEY= # Long, secure, secret string.
|
||||||
DATA=./data/
|
DATA=./data/
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -149,4 +149,7 @@ ref-test/testing.py
|
|||||||
database/data/
|
database/data/
|
||||||
|
|
||||||
# Ignore Encryption Keyfile
|
# 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.
|
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.
|
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.
|
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'
|
version: '3.9'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
container_name: reftest_server
|
container_name: reftest_server
|
||||||
@ -8,6 +11,7 @@ services:
|
|||||||
- ./certbot:/etc/letsencrypt:ro
|
- ./certbot:/etc/letsencrypt:ro
|
||||||
- ./nginx:/etc/nginx
|
- ./nginx:/etc/nginx
|
||||||
- ./src/html:/usr/share/nginx/html/
|
- ./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/admin/static:/usr/share/nginx/html/admin/static
|
||||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
|
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
|
||||||
- ./ref-test/app/root:/usr/share/nginx/html/root
|
- ./ref-test/app/root:/usr/share/nginx/html/root
|
||||||
@ -30,7 +34,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 5000
|
- 5000
|
||||||
volumes:
|
volumes:
|
||||||
- ./ref-test/data:/ref-test/data
|
- data:/ref-test/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- frontend
|
- frontend
|
||||||
|
@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
|
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
|
||||||
|
|
||||||
if ! [ -x "$(command -v docker compose)" ]; then
|
if ! [ -x "$(command -v docker)" ]; then
|
||||||
|
echo 'Error: docker is not installed.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [ -x "$(command -v compose)" ]; then
|
||||||
echo 'Error: docker compose is not installed.' >&2
|
echo 'Error: docker compose is not installed.' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -41,8 +46,6 @@ if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
|
|||||||
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
|
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
|
||||||
wget -O $data_path/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"
|
"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
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -74,7 +77,7 @@ esac
|
|||||||
if [ $staging != "0" ]; then staging_arg="--staging"; fi
|
if [ $staging != "0" ]; then staging_arg="--staging"; fi
|
||||||
|
|
||||||
docker compose run --rm --entrypoint "\
|
docker compose run --rm --entrypoint "\
|
||||||
certbot certonly --webroot -w /var/www/html \
|
certbot certonly --non-interactive --webroot -w /var/www/html \
|
||||||
$staging_arg \
|
$staging_arg \
|
||||||
$email_arg \
|
$email_arg \
|
||||||
$domain_args \
|
$domain_args \
|
||||||
|
@ -29,6 +29,11 @@ server {
|
|||||||
alias /usr/share/nginx/html/admin/static/;
|
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 / {
|
location / {
|
||||||
include /etc/nginx/conf.d/proxy_headers.conf;
|
include /etc/nginx/conf.d/proxy_headers.conf;
|
||||||
proxy_pass http://reftest;
|
proxy_pass http://reftest;
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
FROM python:3.10-slim
|
FROM python:3.10-slim
|
||||||
|
ARG DATA=./data/
|
||||||
|
ENV DATA=$DATA
|
||||||
WORKDIR /ref-test
|
WORKDIR /ref-test
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
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" ]
|
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]
|
@ -1,5 +1,4 @@
|
|||||||
from .config import Development as Config
|
from .config import Production as Config
|
||||||
from .install import install_app
|
|
||||||
from .models import User
|
from .models import User
|
||||||
from .extensions import bootstrap, csrf, db, login_manager, mail
|
from .extensions import bootstrap, csrf, db, login_manager, mail
|
||||||
|
|
||||||
@ -53,7 +52,5 @@ def create_app():
|
|||||||
app.register_blueprint(views)
|
app.register_blueprint(views)
|
||||||
app.register_blueprint(quiz)
|
app.register_blueprint(quiz)
|
||||||
app.register_blueprint(editor, url_prefix='/admin/editor')
|
app.register_blueprint(editor, url_prefix='/admin/editor')
|
||||||
|
|
||||||
install_app(app)
|
|
||||||
|
|
||||||
return app
|
return app
|
@ -96,23 +96,32 @@ $('.test-action').click(function(event) {
|
|||||||
// Edit Dataset Button Handlers
|
// Edit Dataset Button Handlers
|
||||||
$('.edit-question-dataset').click(function(event) {
|
$('.edit-question-dataset').click(function(event) {
|
||||||
|
|
||||||
var filename = $(this).data('filename');
|
var id = $(this).data('id');
|
||||||
var action = $(this).data('action');
|
var action = $(this).data('action');
|
||||||
var disabled = $(this).hasClass('disabled');
|
var disabled = $(this).hasClass('disabled');
|
||||||
|
|
||||||
if ( !disabled ) {
|
if ( !disabled ) {
|
||||||
$.ajax({
|
if (action == 'delete') {
|
||||||
url: `/admin/settings/questions/${action}/`,
|
$.ajax({
|
||||||
type: 'POST',
|
url: `/admin/settings/questions/${action}/`,
|
||||||
data: JSON.stringify({'filename': filename}),
|
type: 'POST',
|
||||||
contentType: 'application/json',
|
data: JSON.stringify({
|
||||||
success: function(response) {
|
'id': id,
|
||||||
window.location.reload();
|
'action': action,
|
||||||
},
|
}),
|
||||||
error: function(response){
|
contentType: 'application/json',
|
||||||
error_response(response);
|
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();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form">Log In</h2>
|
<h2 class="form">Log In</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form-heading">Update Password</h2>
|
<h2 class="form-heading">Update Password</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ entry.get_email() }}
|
{{ entry.get_email() }}
|
||||||
</li>
|
</li>
|
||||||
{% if entry.club %}
|
{% if entry.get_club() %}
|
||||||
<li class="list-group-item list-group-item-action">
|
<li class="list-group-item list-group-item-action">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">Club</h5>
|
<h5 class="mb-1">Club</h5>
|
||||||
|
@ -79,6 +79,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="nav-account">
|
<li class="nav-item dropdown" id="nav-account">
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ entry.get_email() }}
|
{{ entry.get_email() }}
|
||||||
</li>
|
</li>
|
||||||
{% if entry.club %}
|
{% if entry.get_club() %}
|
||||||
<li class="list-group-item list-group-item-action">
|
<li class="list-group-item list-group-item-action">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">Club</h5>
|
<h5 class="mb-1">Club</h5>
|
||||||
|
@ -40,7 +40,7 @@
|
|||||||
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if entry.club %}
|
{% if entry.get_club() %}
|
||||||
{{ entry.get_club() }}
|
{{ entry.get_club() }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
@ -57,7 +57,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
Uploaded
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
Exams
|
Exams
|
||||||
@ -68,7 +68,9 @@
|
|||||||
{% for dataset in datasets %}
|
{% for dataset in datasets %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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>
|
||||||
<td>
|
<td>
|
||||||
{{ dataset.tests|length }}
|
{{ dataset.tests|length }}
|
||||||
|
@ -9,9 +9,12 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th data-priority="2">
|
<th data-priority="2">
|
||||||
Uploaded
|
Updated
|
||||||
</th>
|
</th>
|
||||||
<th data-priority="3">
|
<th data-priority="3">
|
||||||
Author
|
Author
|
||||||
@ -36,6 +39,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ element.get_name() }}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ element.date.strftime('%d %b %Y %H:%M') }}
|
{{ element.date.strftime('%d %b %Y %H:%M') }}
|
||||||
</td>
|
</td>
|
||||||
@ -47,18 +53,27 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="row-actions">
|
<td class="row-actions">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="javascript:void(0)"
|
||||||
class="btn btn-primary edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
|
class="btn btn-primary edit-question-dataset"
|
||||||
data-filename="{{ element.filename }}"
|
data-id="{{ element.id }}"
|
||||||
data-action="default"
|
data-action="download"
|
||||||
title="Make Default"
|
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>
|
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="javascript:void(0)"
|
||||||
class="btn btn-danger edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
|
class="btn btn-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
|
||||||
data-filename="{{ element.filename }}"
|
data-id="{{ element.id }}"
|
||||||
data-action="delete"
|
data-action="delete"
|
||||||
title="Delete Dataset"
|
title="Delete Dataset"
|
||||||
>
|
>
|
||||||
@ -72,13 +87,23 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-primary alert-db-empty">
|
<div class="alert alert-primary alert-db-empty">
|
||||||
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<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">
|
<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>
|
<h2 class="form-heading">Upload Question Dataset</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ 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">
|
<div class="form-upload">
|
||||||
{{ form.data_file() }}
|
{{ form.data_file() }}
|
||||||
</div>
|
</div>
|
||||||
@ -89,8 +114,8 @@
|
|||||||
<div class="container form-submission-button">
|
<div class="container form-submission-button">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col text-center">
|
<div class="col text-center">
|
||||||
<button title="Create User" class="btn btn-md btn-success btn-block" type="submit">
|
<button title="Upload Dataset" class="btn btn-md btn-success btn-block" type="submit">
|
||||||
<i class="bi bi-file-earmark-arrow-up-fill button-icon"></i>
|
<i class="bi bi-cloud-arrow-up-fill button-icon"></i>
|
||||||
Upload Dataset
|
Upload Dataset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -106,10 +131,10 @@
|
|||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$('#question-datasets-table').DataTable({
|
$('#question-datasets-table').DataTable({
|
||||||
'columnDefs': [
|
'columnDefs': [
|
||||||
{'sortable': false, 'targets': [0,4]},
|
{'sortable': false, 'targets': [0,5]},
|
||||||
{'searchable': false, 'targets': [0,3,4]}
|
{'searchable': false, 'targets': [1,2,3]}
|
||||||
],
|
],
|
||||||
'order': [[1, 'desc'], [2, 'asc']],
|
'order': [[1, 'asc'], [2, 'desc'], [3, 'asc']],
|
||||||
'responsive': 'true',
|
'responsive': 'true',
|
||||||
'fixedHeader': '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.data import check_is_json, validate_json
|
||||||
from ..tools.test import answer_options, get_correct_answers
|
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.helpers import flash, url_for
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
from json import loads
|
from json import loads
|
||||||
|
from os import path
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
admin = Blueprint(
|
admin = Blueprint(
|
||||||
@ -90,7 +91,7 @@ def _register():
|
|||||||
flash(message=message, category='error')
|
flash(message=message, category='error')
|
||||||
return jsonify({'error': message}), 401
|
return jsonify({'error': message}), 401
|
||||||
return send_errors_to_client(form=form)
|
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'])
|
@admin.route('/reset/', methods=['GET','POST'])
|
||||||
def _reset():
|
def _reset():
|
||||||
@ -116,7 +117,8 @@ def _reset():
|
|||||||
user.clear_reset_tokens()
|
user.clear_reset_tokens()
|
||||||
if request.args.get('verification') == verification_token:
|
if request.args.get('verification') == verification_token:
|
||||||
form = UpdatePassword()
|
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')
|
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)
|
return render_template('/admin/auth/reset.html', form=form)
|
||||||
@ -125,7 +127,7 @@ def _reset():
|
|||||||
def _update_password():
|
def _update_password():
|
||||||
form = UpdatePassword()
|
form = UpdatePassword()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
user = request.form.get('user')
|
user = session.pop('user')
|
||||||
user = User.query.filter_by(id=user).first()
|
user = User.query.filter_by(id=user).first()
|
||||||
user.update(password=request.form.get('password'))
|
user.update(password=request.form.get('password'))
|
||||||
session['remembered_username'] = user.get_username()
|
session['remembered_username'] = user.get_username()
|
||||||
@ -207,10 +209,13 @@ def _questions():
|
|||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
upload = form.data_file.data
|
upload = form.data_file.data
|
||||||
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
|
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 = Dataset()
|
||||||
|
new_dataset.set_name(request.form.get('name'))
|
||||||
success, message = new_dataset.create(
|
success, message = new_dataset.create(
|
||||||
upload = upload,
|
data = data,
|
||||||
default = request.form.get('default')
|
default = request.form.get('default')
|
||||||
)
|
)
|
||||||
if success: return jsonify({'success': message}), 200
|
if success: return jsonify({'success': message}), 200
|
||||||
@ -220,18 +225,25 @@ def _questions():
|
|||||||
data = Dataset.query.all()
|
data = Dataset.query.all()
|
||||||
return render_template('/admin/settings/questions.html', form=form, data=data)
|
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
|
@login_required
|
||||||
def _edit_questions():
|
def _edit_questions():
|
||||||
id = request.get_json()['id']
|
id = request.get_json()['id']
|
||||||
action = request.get_json()['action']
|
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()
|
dataset = Dataset.query.filter_by(id=id).first()
|
||||||
if action == 'delete': success, message = dataset.delete()
|
if action == 'delete': success, message = dataset.delete()
|
||||||
elif action == 'default': success, message = dataset.make_default()
|
|
||||||
if success: return jsonify({'success': message}), 200
|
if success: return jsonify({'success': message}), 200
|
||||||
return jsonify({'error': message}), 400
|
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/<string:filter>/', methods=['GET'])
|
||||||
@admin.route('/tests/', methods=['GET'])
|
@admin.route('/tests/', methods=['GET'])
|
||||||
@login_required
|
@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 not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||||
if filter == 'create':
|
if filter == 'create':
|
||||||
form = CreateTest()
|
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.time_limit.choices = get_time_options()
|
||||||
form.dataset.choices = get_dataset_choices()
|
form.dataset.choices = get_dataset_choices()
|
||||||
form.time_limit.default='none'
|
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 ..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 datetime import datetime, timedelta
|
||||||
from json import loads
|
from json import loads
|
||||||
@ -26,7 +28,7 @@ def _fetch_questions():
|
|||||||
time_adjustment = test.adjustments[user_code]
|
time_adjustment = test.adjustments[user_code]
|
||||||
_time_limit += time_adjustment
|
_time_limit += time_adjustment
|
||||||
end_delta = timedelta(minutes=_time_limit)
|
end_delta = timedelta(minutes=_time_limit)
|
||||||
end_time = datetime.utcnow() + end_delta
|
end_time = datetime.now() + end_delta
|
||||||
else:
|
else:
|
||||||
end_time = None
|
end_time = None
|
||||||
entry.start()
|
entry.start()
|
||||||
@ -63,4 +65,37 @@ def _submit_quiz():
|
|||||||
'success': 'Your submission has been processed. Redirecting you to receive your results.',
|
'success': 'Your submission has been processed. Redirecting you to receive your results.',
|
||||||
'id': id
|
'id': id
|
||||||
}), 200
|
}), 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
|
@ -1,13 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
if not os.getenv('DATA'):
|
load_dotenv('../.env')
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv('../.env')
|
|
||||||
|
|
||||||
class Config(object):
|
class Config(object):
|
||||||
APP_HOST = '0.0.0.0'
|
APP_HOST = '0.0.0.0'
|
||||||
DATA = os.getenv('DATA')
|
DATA = './data/'
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
TESTING = False
|
TESTING = False
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||||
@ -17,16 +15,16 @@ class Config(object):
|
|||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
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_TLS = False
|
||||||
MAIL_USE_SSL = False
|
MAIL_USE_SSL = False
|
||||||
MAIL_DEBUG = False
|
MAIL_DEBUG = False
|
||||||
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||||
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
|
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_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):
|
class Production(Config):
|
||||||
pass
|
pass
|
||||||
|
@ -13,22 +13,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-controls a {
|
.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;
|
margin: 0 10px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-button div {
|
.option-controls a i, .block-controls a i {
|
||||||
margin: 0;
|
font-size: larger;
|
||||||
position: absolute;
|
margin: 2px;
|
||||||
top: 50%;
|
|
||||||
transform: translate(0, -50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-button a {
|
.accordion-button div {
|
||||||
transform: translate(-50%, -50%);
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: 0%;
|
transform: translate(0, -50%);
|
||||||
position: absolute;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-button::after {
|
.accordion-button::after {
|
||||||
@ -43,4 +56,32 @@
|
|||||||
.accordion-error:not(.collapsed) {
|
.accordion-error:not(.collapsed) {
|
||||||
background-color: #bb2d3b;
|
background-color: #bb2d3b;
|
||||||
color: white;
|
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;
|
||||||
}
|
}
|
@ -1,68 +1,499 @@
|
|||||||
const root = $('#editor-root')
|
// Variable Declarations
|
||||||
|
const $root = $('#editor-root')
|
||||||
|
const target = $root.data('target')
|
||||||
|
const id = $root.data('id')
|
||||||
|
|
||||||
var data = [
|
const $control_panel = $('.control-panel')
|
||||||
{
|
const $info_panel = $('.info-panel')
|
||||||
"type": "question",
|
const $editor_panel = $('.editor-panel')
|
||||||
"q_no": 3,
|
|
||||||
"text": "The ball is gathered by the defensive division and a player throws it forward, hitting the korf in the other division.",
|
|
||||||
"options": [
|
|
||||||
"Play on",
|
|
||||||
"Opposite team restart under their defensive post",
|
|
||||||
"Opposite team restart where the ball was thrown from"
|
|
||||||
],
|
|
||||||
"correct": 0,
|
|
||||||
"q_type": "Multiple Choice",
|
|
||||||
"tags": [
|
|
||||||
"scoring from the defence zone"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "question",
|
|
||||||
"q_no": 4,
|
|
||||||
"text": "At the end of the game, after the final whistle, a coach loudly confronts the referee. What can the referee do?",
|
|
||||||
"options": [
|
|
||||||
"Ignore the coach as the match has ended",
|
|
||||||
"Ignore the coach or inform the coach they will be reported",
|
|
||||||
"Ignore the coach, inform the coach they will be reported and/or show the coach a card"
|
|
||||||
],
|
|
||||||
"correct": 2,
|
|
||||||
"q_type": "List",
|
|
||||||
"tags": [
|
|
||||||
"discipline"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
var element_index = 0
|
||||||
if (data[i]['type'] == 'question') {
|
|
||||||
var obj = `
|
// Initialise Sortable and trigger renumbering on end of drag
|
||||||
<div class="accordion-item" id="i${i}">
|
Sortable.create($root.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||||
<h2 class="accordion-header" id="h${i}">
|
|
||||||
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c${i}" aria-expanded="true" aria-controls="c${i}">
|
// Info Button Listener
|
||||||
<div class="float-start">Question ${i+1}</div>
|
$control_panel.find('button').click(function(event){
|
||||||
<a href="javascript:void(0)" class="btn btn-success panel-control" data-id="${i}" data-action="remove">X</a>
|
if ($info_panel.is(":hidden")) {
|
||||||
</div>
|
$editor_panel.hide()
|
||||||
</h2>
|
$info_panel.fadeIn()
|
||||||
<div id="c${i}" class="accordion-collapse collapse" aria-labelledby="h${i}" data-bs-parent="#editor-root">
|
$(this).addClass('active')
|
||||||
<div class="accordion-body">
|
} else {
|
||||||
<div class="question-text">
|
$info_panel.hide()
|
||||||
${data[i]['text']}
|
$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>
|
</div>
|
||||||
<ul>
|
`
|
||||||
<li>
|
options.append(opt)
|
||||||
${data[i]['options'].join("</li><li>")}
|
correct.append(`<option value="${_c}">${_c}</option>`)
|
||||||
</li>
|
}
|
||||||
</ul>
|
}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
`
|
</div>
|
||||||
root.append(obj)
|
`
|
||||||
}
|
element_index ++
|
||||||
|
return question
|
||||||
}
|
}
|
||||||
|
|
||||||
$('.panel-control').click(function(event) {
|
function generate_block(root_container_id) {
|
||||||
console.log($(this).data('id'))
|
var block = `
|
||||||
var id = $(this).data('id')
|
<div class="accordion-item" id="element${element_index}" data-type="block">
|
||||||
$(`#i${id}`).remove()
|
<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)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
@ -42,81 +42,6 @@ $('form.form-post').submit(function(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form Upload Questions - Special case, needs to handle files.
|
|
||||||
$('form[name=form-upload-questions]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var data = new FormData($form[0]);
|
|
||||||
var file = $('input[name=data_file]')[0].files[0]
|
|
||||||
data.append('file', file)
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
processData: false,
|
|
||||||
contentType: false,
|
|
||||||
success: function(response) {
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
error_response(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Edit and Delete Test Button Handlers
|
|
||||||
$('.test-action').click(function(event) {
|
|
||||||
|
|
||||||
let id = $(this).data('id');
|
|
||||||
let action = $(this).data('action');
|
|
||||||
|
|
||||||
if (action == 'delete' || action == 'start' || action == 'end') {
|
|
||||||
$.ajax({
|
|
||||||
url: `/admin/tests/edit/`,
|
|
||||||
type: 'POST',
|
|
||||||
data: JSON.stringify({'id': id, 'action': action}), // TODO Change how CRUD operations work
|
|
||||||
contentType: 'application/json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = '/admin/tests/';
|
|
||||||
},
|
|
||||||
error: function(response){
|
|
||||||
error_response(response);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (action == 'edit') {
|
|
||||||
window.location.href = `/admin/test/${id}/`
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Edit Dataset Button Handlers
|
|
||||||
$('.edit-question-dataset').click(function(event) {
|
|
||||||
|
|
||||||
var filename = $(this).data('filename');
|
|
||||||
var action = $(this).data('action');
|
|
||||||
var disabled = $(this).hasClass('disabled');
|
|
||||||
|
|
||||||
if ( !disabled ) {
|
|
||||||
$.ajax({
|
|
||||||
url: `/admin/settings/questions/${action}/`,
|
|
||||||
type: 'POST',
|
|
||||||
data: JSON.stringify({'filename': filename}),
|
|
||||||
contentType: 'application/json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
error: function(response){
|
|
||||||
error_response(response);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
function error_response(response) {
|
function error_response(response) {
|
||||||
|
|
||||||
const $alert = $("#alert-box");
|
const $alert = $("#alert-box");
|
||||||
@ -168,66 +93,23 @@ $('#dismiss-cookie-alert').click(function(event){
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
})
|
})
|
||||||
|
|
||||||
// Script for Result Actions
|
// Create New Dataset
|
||||||
$('.result-action-buttons').click(function(event){
|
$('.create-new-dataset').click(function(event){
|
||||||
|
|
||||||
var id = $(this).data('id');
|
|
||||||
|
|
||||||
if ($(this).data('result-action') == 'generate') {
|
|
||||||
$.ajax({
|
|
||||||
url: '/admin/certificate/',
|
|
||||||
type: 'POST',
|
|
||||||
data: JSON.stringify({'id': id}),
|
|
||||||
contentType: 'application/json',
|
|
||||||
dataType: 'html',
|
|
||||||
success: function(response) {
|
|
||||||
var display_window = window.open();
|
|
||||||
display_window.document.write(response);
|
|
||||||
},
|
|
||||||
error: function(response){
|
|
||||||
error_response(response);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
var action = $(this).data('result-action')
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.href,
|
|
||||||
type: 'POST',
|
|
||||||
data: JSON.stringify({'id': id, 'action': action}),
|
|
||||||
contentType: 'application/json',
|
|
||||||
success: function(response) {
|
|
||||||
if (action == 'delete') {
|
|
||||||
window.location.href = '/admin/results/';
|
|
||||||
} else window.location.reload();
|
|
||||||
},
|
|
||||||
error: function(response){
|
|
||||||
error_response(response);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Script for Deleting Time Adjustment
|
|
||||||
$('.adjustment-delete').click(function(event){
|
|
||||||
|
|
||||||
var user_code = $(this).data('user_code');
|
|
||||||
var location = window.location.href;
|
|
||||||
location = location.replace('#', '')
|
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: location + 'delete-adjustment/',
|
url: '/api/editor/new/',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: JSON.stringify({'user_code': user_code}),
|
data: {
|
||||||
contentType: 'application/json',
|
time: Date.now()
|
||||||
success: function(response) {
|
},
|
||||||
window.location.reload();
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
if (response.redirect_to) {
|
||||||
|
window.location.href = response.redirect_to;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
error_response(response);
|
console.log(response);
|
||||||
},
|
}
|
||||||
});
|
})
|
||||||
|
event.preventDefault()
|
||||||
event.preventDefault();
|
})
|
||||||
});
|
|
@ -48,6 +48,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||||
|
@ -79,6 +79,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="nav-account">
|
<li class="nav-item dropdown" id="nav-account">
|
||||||
|
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 %}
|
@ -1,140 +1,31 @@
|
|||||||
{% extends "editor/components/base.html" %}
|
{% extends "editor/components/input-forms.html" %}
|
||||||
|
|
||||||
{% block style %}
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="{{ url_for('.static', filename='css/editor.css') }}"
|
|
||||||
/>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Editor</h1>
|
<div class="form-container">
|
||||||
<div class="container editor-panel" id="editor" tabindex="-1">
|
<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) }}">
|
||||||
<div class="accordion" id="editor-root">
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<div class="accordion-item" id="i0">
|
<h2 class="form">Dataset Editor</h2>
|
||||||
<h2 class="accordion-header" id="h0">
|
{{ form.hidden_tag() }}
|
||||||
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c0" aria-expanded="true" aria-controls="c0">
|
<div class="form-select-input">
|
||||||
<div class="float-start">Question 1</div>
|
{{ form.dataset(placeholder="Select Question Dataset") }}
|
||||||
<a href="javascript:void(0)" class="btn btn-success panel-control" data-id="0" data-action="remove">X</a>
|
{{ form.dataset.label }}
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
{% include "admin/components/client-alerts.html" %}
|
||||||
<div id="c0" class="accordion-collapse collapse" aria-labelledby="h0" data-bs-parent="#editor-root">
|
<div class="container form-submission-button">
|
||||||
<div class="accordion-body">
|
<div class="row">
|
||||||
<div class="question-text">
|
<div class="col text-center">
|
||||||
<div class="input-group mb-3">
|
<button class="btn btn-md btn-success btn-block" type="submit">
|
||||||
<span class="input-group-text">Question 1</span>
|
<i class="bi bi-pencil-fill button-icon"></i>
|
||||||
<textarea type="text" class="form-control" id="q0-text" aria-describedby="q0-text-caption">Placeholder for Question 1</textarea>
|
Edit
|
||||||
</div>
|
</button>
|
||||||
<div class="input-group mb-3">
|
<button title="New" class="btn btn-md btn-primary create-new-dataset">
|
||||||
<span class="input-group-text">Question Type</span>
|
<i class="bi bi-cloud-plus-fill button-icon"></i>
|
||||||
<select id="q0-type" class="form-select">
|
New
|
||||||
<option value ="Multiple Choice">Multiple Choice</option>
|
</button>
|
||||||
<option value="Yes/No">Yes/No</option>
|
|
||||||
<option value="List">Ordered List</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<label for="q0-options" class="form-label">Options</label>
|
|
||||||
<ul class="options" id="q0-options">
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="q0-options-0-caption">0</span>
|
|
||||||
<input type="text" class="form-control" value="Text for Option 1" id="q0-options-0" aria-describedby="q0-options-0-caption">
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="q0-options-1-caption">1</span>
|
|
||||||
<input type="text" class="form-control" value="Text for Option 2" id="q0-options-1" aria-describedby="q-options-1-caption">
|
|
||||||
</div>
|
|
||||||
</ul>
|
|
||||||
<div class="editor-controls">
|
|
||||||
<a href="" class="btn btn-danger" data-action="Cancel">Cancel</a>
|
|
||||||
<a href="" class="btn btn-success" data-action="Done">Done</a>
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="q0-correct-caption">Correct</span>
|
|
||||||
<input type="text" class="form-control" value="0" id="q0-correct" aria-describedby="q0-correct-caption">
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="q0-tags-caption">Tags</span>
|
|
||||||
<textarea type="text" class="form-control" value="Foo" id="q0-tags" aria-describedby="q0-tags-caption">List of tags
List of tags</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="accordion-item" id="i1">
|
</form>
|
||||||
<h2 class="accordion-header" id="h1">
|
|
||||||
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c1" aria-expanded="true" aria-controls="c1">
|
|
||||||
<div class="float-start">Block 1</div>
|
|
||||||
<a href="javascript:void(0)" class="btn btn-success panel-control" data-id="0" data-action="remove">X</a>
|
|
||||||
</div>
|
|
||||||
</h2>
|
|
||||||
<div id="c1" class="accordion-collapse collapse" aria-labelledby="h1" data-bs-parent="#editor-root">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<div class="accordion" id="b1">
|
|
||||||
<div class="accordion-item" id="b1-item">
|
|
||||||
<h2 class="accordion-header" id="h2">
|
|
||||||
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c2" aria-expanded="true" aria-controls="c2">
|
|
||||||
<div class="float-start">Question 1</div>
|
|
||||||
<a href="javascript:void(0)" class="btn btn-success panel-control" data-id="0" data-action="remove">X</a>
|
|
||||||
</div>
|
|
||||||
</h2>
|
|
||||||
<div id="c2" class="accordion-collapse collapse" aria-labelledby="h2" data-bs-parent="#b1">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<div class="question-text">
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text">Question 1</span>
|
|
||||||
<textarea type="text" class="form-control" id="q0-text" aria-describedby="q0-text-caption">Placeholder for Question 1</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text">Question Type</span>
|
|
||||||
<select id="q0-type" class="form-select">
|
|
||||||
<option value ="Multiple Choice">Multiple Choice</option>
|
|
||||||
<option value="Yes/No">Yes/No</option>
|
|
||||||
<option value="List">Ordered List</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<label for="q1-options" class="form-label">Options</label>
|
|
||||||
<ul class="options" id="q1-options">
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="q0-options-0-caption">0</span>
|
|
||||||
<input type="text" class="form-control" value="Text for Option 1" id="q0-options-0" aria-describedby="q0-options-0-caption">
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="q0-options-1-caption">1</span>
|
|
||||||
<input type="text" class="form-control" value="Text for Option 2" id="q0-options-1" aria-describedby="q-options-1-caption">
|
|
||||||
</div>
|
|
||||||
</ul>
|
|
||||||
<div class="editor-controls">
|
|
||||||
<a href="" class="btn btn-danger" data-action="Cancel">Cancel</a>
|
|
||||||
<a href="" class="btn btn-success" data-action="Done">Done</a>
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="q0-correct-caption">Correct</span>
|
|
||||||
<input type="text" class="form-control" value="0" id="q0-correct" aria-describedby="q0-correct-caption">
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="q0-tags-caption">Tags</span>
|
|
||||||
<textarea type="text" class="form-control" value="Foo" id="q0-tags" aria-describedby="q0-tags-caption">List of tags
List of tags</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% include "editor/components/client-alerts.html" %}
|
|
||||||
<div class="editor-controls">
|
|
||||||
<a href="" class="btn btn-danger" data-action="Cancel">Cancel</a>
|
|
||||||
<a href="" class="btn btn-success" data-action="Done">Done</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
<script
|
|
||||||
type="text/javascript"
|
|
||||||
src="{{ url_for('.static', filename='js/editor.js') }}"
|
|
||||||
></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -1,4 +1,10 @@
|
|||||||
from flask import Blueprint, render_template
|
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(
|
editor = Blueprint(
|
||||||
name='editor',
|
name='editor',
|
||||||
@ -7,6 +13,26 @@ editor = Blueprint(
|
|||||||
static_folder='static'
|
static_folder='static'
|
||||||
)
|
)
|
||||||
|
|
||||||
@editor.route('/')
|
@editor.route('/', methods=['GET','POST'])
|
||||||
|
@login_required
|
||||||
def _editor():
|
def _editor():
|
||||||
return render_template('/editor/index.html')
|
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.fields import DateTimeLocalField
|
||||||
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
|
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
|
|
||||||
class Login(FlaskForm):
|
class Login(FlaskForm):
|
||||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
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.')])
|
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.')])
|
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
|
||||||
|
|
||||||
class CreateTest(FlaskForm):
|
class CreateTest(FlaskForm):
|
||||||
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = datetime.now() )
|
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()], default = date.today() + timedelta(days=1) )
|
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
|
||||||
time_limit = SelectField('Time Limit')
|
time_limit = SelectField('Time Limit')
|
||||||
dataset = SelectField('Question Dataset')
|
dataset = SelectField('Question Dataset')
|
||||||
|
|
||||||
class UploadData(FlaskForm):
|
class UploadData(FlaskForm):
|
||||||
|
name = StringField('Name', validators=[InputRequired()])
|
||||||
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
|
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
|
||||||
default = BooleanField('Make Default', render_kw={'checked': True})
|
default = BooleanField('Make Default', render_kw={'checked': True})
|
||||||
|
|
||||||
class AddTimeAdjustment(FlaskForm):
|
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 ..extensions import db
|
||||||
|
from ..tools.encryption import decrypt, encrypt
|
||||||
from ..tools.logs import write
|
from ..tools.logs import write
|
||||||
|
|
||||||
from flask import flash
|
from flask import flash
|
||||||
@ -7,7 +8,7 @@ from flask_login import current_user
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from json import dump, loads
|
from json import dump
|
||||||
from os import path, remove
|
from os import path, remove
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@ -15,13 +16,16 @@ from uuid import uuid4
|
|||||||
class Dataset(db.Model):
|
class Dataset(db.Model):
|
||||||
|
|
||||||
id = db.Column(db.String(36), primary_key=True)
|
id = db.Column(db.String(36), primary_key=True)
|
||||||
|
name = db.Column(db.String(128), nullable=False)
|
||||||
tests = db.relationship('Test', backref='dataset')
|
tests = db.relationship('Test', backref='dataset')
|
||||||
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||||
date = db.Column(db.DateTime, nullable=False)
|
date = db.Column(db.DateTime, nullable=False)
|
||||||
default = db.Column(db.Boolean, default=False, nullable=True)
|
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):
|
def __repr__(self):
|
||||||
return f'<Dataset {self.id}> was added.'
|
return f'<Dataset {self.id}>.'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||||
@ -29,6 +33,14 @@ class Dataset(db.Model):
|
|||||||
generate_id.setter
|
generate_id.setter
|
||||||
def generate_id(self): self.id = uuid4().hex
|
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):
|
def make_default(self):
|
||||||
for dataset in Dataset.query.all():
|
for dataset in Dataset.query.all():
|
||||||
dataset.default = False
|
dataset.default = False
|
||||||
@ -43,7 +55,7 @@ class Dataset(db.Model):
|
|||||||
message = 'Cannot delete the default dataset.'
|
message = 'Cannot delete the default dataset.'
|
||||||
flash(message, 'error')
|
flash(message, 'error')
|
||||||
return False, message
|
return False, message
|
||||||
if Dataset.query.all().count() == 1:
|
if Dataset.query.count() == 1:
|
||||||
message = 'Cannot delete the only dataset.'
|
message = 'Cannot delete the only dataset.'
|
||||||
flash(message, 'error')
|
flash(message, 'error')
|
||||||
return False, message
|
return False, message
|
||||||
@ -56,23 +68,19 @@ class Dataset(db.Model):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return True, 'Dataset deleted.'
|
return True, 'Dataset deleted.'
|
||||||
|
|
||||||
def create(self, upload, default:bool=False):
|
def create(self, data:list, default:bool=False):
|
||||||
self.generate_id()
|
self.generate_id()
|
||||||
timestamp = datetime.now()
|
timestamp = datetime.now()
|
||||||
filename = secure_filename('.'.join([self.id,'json']))
|
file_path = self.get_file()
|
||||||
data = Path(app.config.get('DATA'))
|
|
||||||
file_path = path.join(data, 'questions', filename)
|
|
||||||
upload.stream.seek(0)
|
|
||||||
questions = loads(upload.read())
|
|
||||||
with open(file_path, 'w') as file:
|
with open(file_path, 'w') as file:
|
||||||
dump(questions, file, indent=2)
|
dump(data, file, indent=2)
|
||||||
self.date = timestamp
|
self.date = timestamp
|
||||||
self.creator = current_user
|
self.creator = current_user
|
||||||
if default: self.make_default()
|
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.add(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return True, 'Dataset uploaded.'
|
return True, 'Dataset created.'
|
||||||
|
|
||||||
def check_file(self):
|
def check_file(self):
|
||||||
filename = secure_filename('.'.join([self.id,'json']))
|
filename = secure_filename('.'.join([self.id,'json']))
|
||||||
@ -85,4 +93,16 @@ class Dataset(db.Model):
|
|||||||
filename = secure_filename('.'.join([self.id,'json']))
|
filename = secure_filename('.'.join([self.id,'json']))
|
||||||
data = Path(app.config.get('DATA'))
|
data = Path(app.config.get('DATA'))
|
||||||
file_path = path.join(data, 'questions', filename)
|
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 ..extensions import db
|
||||||
from ..tools.encryption import decrypt, encrypt
|
|
||||||
from ..tools.forms import JsonEncodedDict
|
from ..tools.forms import JsonEncodedDict
|
||||||
from ..tools.logs import write
|
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.'
|
if entry.get_email() == email and not entry == self: return False, f'The email address {email} is already in use.'
|
||||||
self.set_email(email)
|
self.set_email(email)
|
||||||
db.session.commit()
|
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:
|
if notify:
|
||||||
message = Message(
|
message = Message(
|
||||||
subject='RefTest | Account Update',
|
subject='RefTest | Account Update',
|
||||||
@ -202,7 +203,7 @@ class User(UserMixin, db.Model):
|
|||||||
bcc=[old_email,current_user.get_email()],
|
bcc=[old_email,current_user.get_email()],
|
||||||
body=f"""
|
body=f"""
|
||||||
Hello {self.get_username()},\n\n
|
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
|
Your new account details are as follows:\n\n
|
||||||
Email: {email}\n
|
Email: {email}\n
|
||||||
Password: {password if password else '<same as old>'}\n\n
|
Password: {password if password else '<same as old>'}\n\n
|
||||||
@ -213,7 +214,7 @@ class User(UserMixin, db.Model):
|
|||||||
""",
|
""",
|
||||||
html=f"""
|
html=f"""
|
||||||
<p>Hello {self.get_username()},</p>
|
<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>Your new account details are as follows:</p>
|
||||||
<p>Email: {email} <br/> Password: <strong>{password if password else '<same as old>'}</strong></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>
|
<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}),
|
data: JSON.stringify({'id': id}),
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
$(this).fadeOut();
|
$("#btn-start-quiz").fadeOut();
|
||||||
$(".btn-quiz-return").fadeIn();
|
$(".btn-quiz-return").fadeIn();
|
||||||
$(".quiz-console").fadeIn();
|
$(".quiz-console").fadeIn();
|
||||||
$("#quiz-settings").fadeOut();
|
$("#quiz-settings").fadeOut();
|
||||||
|
@ -123,7 +123,7 @@
|
|||||||
<div class="container question-container quiz-start-text">
|
<div class="container question-container quiz-start-text">
|
||||||
<h4 class="question-title">Sample Question</h4>
|
<h4 class="question-title">Sample Question</h4>
|
||||||
<p class="question-header">
|
<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>
|
||||||
<p class="question-text">
|
<p class="question-text">
|
||||||
In order to be a referee, what do you need to know?
|
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 />
|
<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 />
|
<strong class="results-details">Club</strong>: {{ entry.get_club() }} <br />
|
||||||
{% endif%}
|
{% 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
|
if not '.' in file.filename or not file.filename.rsplit('.',1)[-1] == 'json': return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def validate_json(file):
|
def validate_json(data):
|
||||||
file.stream.seek(0)
|
|
||||||
data = json.loads(file.read())
|
|
||||||
if not isinstance(data, list): return False
|
if not isinstance(data, list): return False
|
||||||
for block in data:
|
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 not in ['block', 'question']: return False
|
||||||
if block_type == 'question':
|
if block_type == 'question':
|
||||||
if not all (key in block for key in ['q_no', 'text', 'options', 'correct', 'q_type', 'tags']): return False
|
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()
|
datasets = Dataset.query.all()
|
||||||
dataset_choices = []
|
dataset_choices = []
|
||||||
for dataset in datasets:
|
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
|
label = f'{label} (Default)' if dataset.default else label
|
||||||
choice = (dataset.id, label)
|
choice = (dataset.id, label)
|
||||||
dataset_choices.append(choice)
|
dataset_choices.append(choice)
|
||||||
return dataset_choices
|
return dataset_choices
|
||||||
|
|
||||||
def send_errors_to_client(form):
|
def send_errors_to_client(form):
|
||||||
errors = [*form.errors]
|
errors = [*form.errors.values()]
|
||||||
return jsonify({ 'error': errors}), 400
|
return jsonify({ 'error': errors}), 400
|
@ -22,7 +22,7 @@ def _cookie_consent():
|
|||||||
value='true',
|
value='true',
|
||||||
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else None,
|
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else None,
|
||||||
path = '/',
|
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")}',
|
domain = f'{app.config.get("SERVER_NAME")}',
|
||||||
secure = True
|
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