Compare commits
133 Commits
Author | SHA1 | Date | |
---|---|---|---|
502e694a17 | |||
d28cd6daed | |||
58782f6db7 | |||
57b25cd214 | |||
666e12253e | |||
8013a776a9 | |||
aa1f46ee62 | |||
dbd8d6bbe3 | |||
fed46eaa1e | |||
79ad96a93f | |||
ba851cb7dc | |||
fcc4d55947 | |||
a56358b8dd | |||
179a608089 | |||
a1289da09c | |||
ea86fd9ae6 | |||
76d60546e2 | |||
9a02048199 | |||
c9ad8e87cd | |||
3714919ba5 | |||
1026cc71a9 | |||
07fb170656 | |||
1ea93994ab | |||
607b132996 | |||
7aa4d81e65 | |||
0ef39dcfbe | |||
e1517b89c0 | |||
d0ed228824 | |||
a2c52a4261 | |||
b2c9bdd7d2 | |||
7536c33a48 | |||
850c2b13b7 | |||
eb69979f59 | |||
95cea46a8f | |||
02a1129390 | |||
438e09f1ec | |||
9241e1c0f7 | |||
8deefb9035 | |||
4f2984deea | |||
70d2325579 | |||
36d840c752 | |||
4400446718 | |||
adead30a77 | |||
487f24732d | |||
3c06cebddf | |||
d1d52fa4b6 | |||
80dc8b3cff | |||
a9ccd64de2 | |||
f5b9758bb1 | |||
84570d5974 | |||
edb8241ad3 | |||
644a539ed9 | |||
f05568b0de | |||
da4a3e41c6 | |||
77f86f7102 | |||
358695977f | |||
ddfd75c1f8 | |||
f4642767ac | |||
2f729de40b | |||
d68beb938f | |||
ca667f7896 | |||
0cc00ef911 | |||
5ec2a86d08 | |||
cd57eca7d3 | |||
a46338fdcb | |||
40f1cebb7b | |||
2a6478f3cf | |||
b6e250a7cd | |||
bcee2eedd0 | |||
d9837246de | |||
62fac48904 | |||
2bf0eeb33d | |||
72f2af1df8 | |||
168b2b288a | |||
9a5f69f889 | |||
7d6f256392 | |||
866c9b10cf | |||
b8fd65d856 | |||
5490bd083f | |||
3cb78055ff | |||
f9d85a8028 | |||
4f193e7fa5 | |||
df3149abba | |||
7ab87c2966 | |||
f4f501def5 | |||
1c57950558 | |||
f132cdbeef | |||
0387c05055 | |||
552b2ffc47 | |||
a2e859af5d | |||
81b09190de | |||
ed100ee9e5 | |||
5dc6c4998d | |||
0d68233d41 | |||
4caac25b14 | |||
3defe020f5 | |||
f14085f4c1 | |||
be5343a4bd | |||
2da8eb7712 | |||
3a0abaac6a | |||
b15f76701e | |||
02290e968c | |||
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 |
15
.env.example
15
.env.example
@ -1,8 +1,21 @@
|
|||||||
SERVER_NAME= # URL where this will be hosted.
|
SERVER_NAME= # URL where this will be hosted.
|
||||||
|
FLASK_DEBUG=False
|
||||||
|
|
||||||
## Flask Configuration
|
TZ=Europe/London # Time Zone
|
||||||
|
|
||||||
|
## App Configuration
|
||||||
SECRET_KEY= # Long, secure, secret string.
|
SECRET_KEY= # Long, secure, secret string.
|
||||||
DATA=./data/
|
DATA=./data/
|
||||||
|
DATABASE_TYPE=SQLite # SQLite or MySQL, defaults to SQLite
|
||||||
|
DATABASE_HOST= # Required if MySQL. Must match name of Docker service, or provide host if database is on an external server. Defaults to localhost.
|
||||||
|
DATABASE_PORT= # Required if MySQL. Defaults to 3306
|
||||||
|
|
||||||
|
## MySQL Database Configuration (Required if configured to MySQL Database.)
|
||||||
|
# Note that if using the Docker service, these configuration values will also be used when creating the database in the mysql container.
|
||||||
|
MYSQL_RANDOM_ROOT_PASSWORD=True
|
||||||
|
MYSQL_DATABASE= # Required if MySQL.
|
||||||
|
MYSQL_USER= # Required if MySQL
|
||||||
|
MYSQL_PASSWORD= # Required if MySQL. Create secure password string. Note '@' character cannot be used.
|
||||||
|
|
||||||
## Flask Mail Configuration
|
## Flask Mail Configuration
|
||||||
MAIL_SERVER=postfix # Must match name of the Docker service
|
MAIL_SERVER=postfix # Must match name of the Docker service
|
||||||
|
13
.gitignore
vendored
13
.gitignore
vendored
@ -150,3 +150,16 @@ database/data/
|
|||||||
|
|
||||||
# Ignore Encryption Keyfile
|
# Ignore Encryption Keyfile
|
||||||
.encryption.key
|
.encryption.key
|
||||||
|
|
||||||
|
# Ignore Data Dir
|
||||||
|
**/data/*
|
||||||
|
|
||||||
|
# Ignore Logs Dir
|
||||||
|
logs/*
|
||||||
|
|
||||||
|
# Ignore Certbot Dir
|
||||||
|
certbot/*
|
||||||
|
|
||||||
|
# Ignore src dir (exception for robots.txt)
|
||||||
|
src/html/*
|
||||||
|
src/html/robots.txt
|
89
README.md
89
README.md
@ -10,7 +10,7 @@ The exam client is made with accessibility in mind, and has been designed to be
|
|||||||
|
|
||||||
## Set Up and Installation
|
## Set Up and Installation
|
||||||
|
|
||||||
The clien is designed to work on a server.
|
The app is designed to be hosted on a server.
|
||||||
|
|
||||||
### Pre-Requisites
|
### Pre-Requisites
|
||||||
|
|
||||||
@ -29,12 +29,11 @@ To set up the server, consult some of the comprehensive guides on various hostin
|
|||||||
Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04).
|
Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04).
|
||||||
|
|
||||||
To install Docker and Docker Compose, consult the respective documentation:
|
To install Docker and Docker Compose, consult the respective documentation:
|
||||||
|
|
||||||
- [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/)
|
- [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/)
|
||||||
- Docker Compose should be installed as part of that.
|
- Docker Compose should be installed as part of that.
|
||||||
|
|
||||||
```
|
> At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions.
|
||||||
At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions.
|
|
||||||
```
|
|
||||||
|
|
||||||
Check if Git is installed on your server using the `git --version` command.
|
Check if Git is installed on your server using the `git --version` command.
|
||||||
If it isn't installed, install it.
|
If it isn't installed, install it.
|
||||||
@ -58,6 +57,16 @@ Once in the destination folder, clone all the relevant files you will need for t
|
|||||||
|
|
||||||
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
|
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
|
||||||
|
|
||||||
|
#### Choose What Database Engine You Will Use
|
||||||
|
|
||||||
|
This app is designed to use an SQLite database by default.
|
||||||
|
You can set it up to use a MySQL database by configuring the environment variables accordingly.
|
||||||
|
If your database is being hosted remotely, make sure the MySQL database has the proper authentication for the user from a remote server.
|
||||||
|
Alternatively, you can also use the second `docker-compose-mysql.yml` file which provides a MySQL database as part of the cluster.
|
||||||
|
To use the second `docker-compose-mysql.yml` file, use the following command at the last step of the installation:
|
||||||
|
|
||||||
|
```sudo docker compose -f docker-compose-mysql.yml up```
|
||||||
|
|
||||||
#### Populate Environment Variables
|
#### Populate Environment Variables
|
||||||
|
|
||||||
Configuration values for the app are stored in the environment variables file.
|
Configuration values for the app are stored in the environment variables file.
|
||||||
@ -72,13 +81,13 @@ Also make sure that the various entries for usernames and passwords match.
|
|||||||
|
|
||||||
There are some values in the following four files you will need to configure to reflect the domain you are installing this app.
|
There are some values in the following four files you will need to configure to reflect the domain you are installing this app.
|
||||||
|
|
||||||
```
|
```sh
|
||||||
# .env
|
# .env
|
||||||
|
|
||||||
SERVER_NAME= # URL where this will be hosted.
|
SERVER_NAME= # URL where this will be hosted.
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```sh
|
||||||
# install-script.sh
|
# install-script.sh
|
||||||
|
|
||||||
domains=(example.org www.example.org)
|
domains=(example.org www.example.org)
|
||||||
@ -87,7 +96,7 @@ email="" # Adding a valid address is strongly recommended
|
|||||||
|
|
||||||
Substitute the domain name `domain_name` in the two file paths in the following file:
|
Substitute the domain name `domain_name` in the two file paths in the following file:
|
||||||
|
|
||||||
```
|
```sh
|
||||||
# nginx/ssl.conf
|
# nginx/ssl.conf
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||||
@ -95,9 +104,9 @@ ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
And **six** locations in the following file, two for the regular version of the domain and two for the www version:
|
And **six** locations in the following file, two for the regular version of the domain and four for the www version (remember to keep the www. prefix where present):
|
||||||
|
|
||||||
```
|
```nginx
|
||||||
# nginx/conf.d/ref-test-app.conf
|
# nginx/conf.d/ref-test-app.conf
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@ -140,9 +149,9 @@ This will be set up automatically.
|
|||||||
However, there is a specific chicken-and-egg problem as the web server, Nginx, won't run without certificates, Certbot, the certificate generator, won't run without the web server.
|
However, there is a specific chicken-and-egg problem as the web server, Nginx, won't run without certificates, Certbot, the certificate generator, won't run without the web server.
|
||||||
So to solve this, there is an automation script we can run that will set up a dummy certificate and then issue the appropriate certificates for us.
|
So to solve this, there is an automation script we can run that will set up a dummy certificate and then issue the appropriate certificates for us.
|
||||||
|
|
||||||
```
|
```sh
|
||||||
$ chmod +x install-script.sh
|
chmod +x install-script.sh
|
||||||
$ sudo ./install-script.sh
|
sudo ./install-script.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will take a long time to run the first time because it will try and generate a fairly sizeable cypher.
|
This will take a long time to run the first time because it will try and generate a fairly sizeable cypher.
|
||||||
@ -166,3 +175,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```
|
||||||
|
@ -6,11 +6,6 @@
|
|||||||
|
|
||||||
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/compose-file-v3/)
|
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/compose-file-v3/)
|
||||||
|
|
||||||
### MongoDB/PyMongo
|
|
||||||
|
|
||||||
- [MongoDB Shell Commands](https://docs.mongodb.com/manual/reference/)
|
|
||||||
- [PyMongo Driver](https://pymongo.readthedocs.io/en/stable/)
|
|
||||||
|
|
||||||
## Source Code
|
## Source Code
|
||||||
|
|
||||||
- [MongoDB Docker Image entrypoint shell script](https://github.com/docker-library/mongo/blob/master/5.0/docker-entrypoint.sh) (Context: Tried to replicate the command to create a new user in the original entrypoint script in the custom initialisation script in this app.)
|
- [MongoDB Docker Image entrypoint shell script](https://github.com/docker-library/mongo/blob/master/5.0/docker-entrypoint.sh) (Context: Tried to replicate the command to create a new user in the original entrypoint script in the custom initialisation script in this app.)
|
||||||
@ -23,15 +18,6 @@
|
|||||||
- [Tables](https://www.blog.pythonlibrary.org/2017/12/14/flask-101-adding-editing-and-displaying-data/)
|
- [Tables](https://www.blog.pythonlibrary.org/2017/12/14/flask-101-adding-editing-and-displaying-data/)
|
||||||
- [Tables, but interactive](https://blog.miguelgrinberg.com/post/beautiful-interactive-tables-for-your-flask-templates)
|
- [Tables, but interactive](https://blog.miguelgrinberg.com/post/beautiful-interactive-tables-for-your-flask-templates)
|
||||||
|
|
||||||
## Stack Exchange/Overflow
|
|
||||||
|
|
||||||
### MongoDB
|
|
||||||
|
|
||||||
- [Creating MongoDB Database on Container Start](https://stackoverflow.com/questions/42912755/how-to-create-a-db-for-mongodb-container-on-start-up)
|
|
||||||
- [Passing Environment Variables to Docker Container Entrypoint](https://stackoverflow.com/questions/64606674/how-can-i-pass-environment-variables-to-mongo-docker-entrypoint-initdb-d)
|
|
||||||
- [Integrating Flask-Login with MongoDB](https://stackoverflow.com/questions/54992412/flask-login-usermixin-class-with-a-mongodb) (**This does not work with the app as is, and is possibly something that needs more research and development in the future**)
|
|
||||||
- [Setting up a Postfix email notification system](https://medium.com/@vietgoeswest/a-simple-outbound-email-service-for-your-app-in-15-minutes-cc4da70a2af7)
|
|
||||||
|
|
||||||
## YouTube Tutorials
|
## YouTube Tutorials
|
||||||
|
|
||||||
### General Flask Introduction
|
### General Flask Introduction
|
||||||
@ -72,7 +58,7 @@ A much simpler and more rudimentary introduction to Flask and MongoDB.
|
|||||||
- [Build a User Login System with `flask-login`, `flask-wtforms`, `flask-bootstrap`, and `flask-sqlalchemy`](https://www.youtube.com/watch?v=8aTnmsDMldY)
|
- [Build a User Login System with `flask-login`, `flask-wtforms`, `flask-bootstrap`, and `flask-sqlalchemy`](https://www.youtube.com/watch?v=8aTnmsDMldY)
|
||||||
|
|
||||||
A much more robust method that uses the various Flask modules to make a more powerful framework.
|
A much more robust method that uses the various Flask modules to make a more powerful framework.
|
||||||
Uses SQL rather than MongoDB.
|
Uses SQL.
|
||||||
|
|
||||||
### Flask techniques
|
### Flask techniques
|
||||||
|
|
||||||
@ -80,4 +66,4 @@ Uses SQL rather than MongoDB.
|
|||||||
|
|
||||||
### Flask handling file uploads
|
### Flask handling file uploads
|
||||||
|
|
||||||
- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
|
- [Handling File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
|
||||||
|
2
certbot/.gitignore
vendored
2
certbot/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
90
docker-compose-mysql.yml
Normal file
90
docker-compose-mysql.yml
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app:
|
||||||
|
mysql:
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
container_name: reftest_server
|
||||||
|
image: nginx:alpine
|
||||||
|
volumes:
|
||||||
|
- ./certbot:/etc/letsencrypt:ro
|
||||||
|
- ./nginx:/etc/nginx
|
||||||
|
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
|
||||||
|
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
|
||||||
|
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
|
||||||
|
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
|
||||||
|
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
|
||||||
|
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
|
||||||
|
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
- 443:443
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||||
|
|
||||||
|
app:
|
||||||
|
container_name: reftest_app
|
||||||
|
image: reftest
|
||||||
|
build: ./ref-test
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
ports:
|
||||||
|
- 5000
|
||||||
|
volumes:
|
||||||
|
- app:/ref-test/data
|
||||||
|
- ./logs:/ref-test/data/logs
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
postfix:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
postfix:
|
||||||
|
container_name: reftest_postfix
|
||||||
|
image: catatnight/postfix:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
ports:
|
||||||
|
- 25
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
container_name: reftest_certbot
|
||||||
|
image: certbot/certbot
|
||||||
|
volumes:
|
||||||
|
- ./certbot:/etc/letsencrypt
|
||||||
|
- ./src/html/certbot:/var/www/html
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
container_name: reftest_db
|
||||||
|
image: mysql:8.0
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
volumes:
|
||||||
|
- mysql:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- 3306
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
external: false
|
||||||
|
backend:
|
||||||
|
external: false
|
@ -1,5 +1,8 @@
|
|||||||
version: '3.9'
|
version: '3.9'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
container_name: reftest_server
|
container_name: reftest_server
|
||||||
@ -7,10 +10,14 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./certbot:/etc/letsencrypt:ro
|
- ./certbot:/etc/letsencrypt:ro
|
||||||
- ./nginx:/etc/nginx
|
- ./nginx:/etc/nginx
|
||||||
- ./src/html:/usr/share/nginx/html/
|
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
|
||||||
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static
|
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
|
||||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
|
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
|
||||||
- ./ref-test/app/root:/usr/share/nginx/html/root
|
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
|
||||||
|
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
|
||||||
|
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
|
||||||
|
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
|
||||||
|
- ./ref-test/app/analysis/static:/usr/share/nginx/html/analysis/static:ro
|
||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
- 443:443
|
- 443:443
|
||||||
@ -30,7 +37,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 5000
|
- 5000
|
||||||
volumes:
|
volumes:
|
||||||
- ./ref-test/data:/ref-test/data
|
- app:/ref-test/data
|
||||||
|
- ./logs:/ref-test/data/logs
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- frontend
|
- frontend
|
||||||
@ -54,7 +62,7 @@ services:
|
|||||||
image: certbot/certbot
|
image: certbot/certbot
|
||||||
volumes:
|
volumes:
|
||||||
- ./certbot:/etc/letsencrypt
|
- ./certbot:/etc/letsencrypt
|
||||||
- ./src/html:/var/www/html
|
- ./src/html/certbot:/var/www/html
|
||||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
@ -46,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
|
||||||
|
|
||||||
@ -79,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 \
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Certbot Renewal
|
# Certbot Renewal
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html/certbot;
|
||||||
allow all;
|
allow all;
|
||||||
default_type "text/plain";
|
default_type "text/plain";
|
||||||
}
|
}
|
@ -19,6 +19,12 @@ server {
|
|||||||
include /etc/nginx/ssl.conf;
|
include /etc/nginx/ssl.conf;
|
||||||
include /etc/nginx/certbot-challenge.conf;
|
include /etc/nginx/certbot-challenge.conf;
|
||||||
|
|
||||||
|
# Define locations for static files to be served by Nginx
|
||||||
|
location ^~ /root/ {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
alias /usr/share/nginx/html/root/;
|
||||||
|
}
|
||||||
|
|
||||||
location ^~ /quiz/static/ {
|
location ^~ /quiz/static/ {
|
||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
alias /usr/share/nginx/html/quiz/static/;
|
alias /usr/share/nginx/html/quiz/static/;
|
||||||
@ -29,6 +35,22 @@ 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/editor/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /admin/view/static/ {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
alias /usr/share/nginx/html/view/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /admin/analysis/static/ {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
alias /usr/share/nginx/html/analysis/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy to the main app for all other requests
|
||||||
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,2 +1,3 @@
|
|||||||
env/
|
env/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
data/
|
@ -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 reset.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,10 +1,10 @@
|
|||||||
from .config import Development as Config
|
from .config import Production as Config
|
||||||
from .install import install_app
|
from .models import *
|
||||||
from .models import User
|
|
||||||
from .extensions import bootstrap, csrf, db, login_manager, mail
|
from .extensions import bootstrap, csrf, db, login_manager, mail
|
||||||
|
from .tools.logs import write
|
||||||
|
|
||||||
from flask import flash, Flask, render_template, request
|
from flask import flash, Flask, render_template, request
|
||||||
from flask.helpers import url_for
|
from flask.helpers import abort, url_for
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from flask_wtf.csrf import CSRFError
|
from flask_wtf.csrf import CSRFError
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
@ -25,13 +25,16 @@ def create_app():
|
|||||||
login_manager.login_view = 'admin._login'
|
login_manager.login_view = 'admin._login'
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def _load_user(id):
|
def _load_user(id):
|
||||||
return User.query.filter_by(id=id).first()
|
try: return User.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when loading user fo login manager: {exception}')
|
||||||
|
return abort(500)
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _check_cookie_consent():
|
def _check_cookie_consent():
|
||||||
if request.cookies.get('cookie_consent'):
|
if request.cookies.get('cookie_consent'):
|
||||||
return
|
return
|
||||||
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/', '/admin/editor/static' ] ]):
|
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/', '/admin/editor/static', '/admin/view/static' ] ]):
|
||||||
return
|
return
|
||||||
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
|
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
|
||||||
|
|
||||||
@ -47,13 +50,21 @@ def create_app():
|
|||||||
from .quiz.views import quiz
|
from .quiz.views import quiz
|
||||||
from .views import views
|
from .views import views
|
||||||
from .editor.views import editor
|
from .editor.views import editor
|
||||||
|
from .view.views import view
|
||||||
|
from .analysis.views import analysis
|
||||||
|
|
||||||
app.register_blueprint(admin, url_prefix='/admin')
|
app.register_blueprint(admin, url_prefix='/admin')
|
||||||
app.register_blueprint(api, url_prefix='/api')
|
app.register_blueprint(api, url_prefix='/api')
|
||||||
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')
|
||||||
|
app.register_blueprint(view, url_prefix='/admin/view')
|
||||||
|
app.register_blueprint(analysis, url_prefix='/admin/analysis')
|
||||||
|
|
||||||
install_app(app)
|
"""Create Database Tables before First Request"""
|
||||||
|
@app.before_first_request
|
||||||
|
def _create_database_tables():
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
return app
|
return app
|
@ -1,25 +1,25 @@
|
|||||||
// Menu Highlight Scripts
|
// Menu Highlight Scripts
|
||||||
const menuItems = document.getElementsByClassName('nav-link');
|
const menuItems = document.getElementsByClassName('nav-link')
|
||||||
for(let i = 0; i < menuItems.length; i++) {
|
for(let i = 0; i < menuItems.length; i++) {
|
||||||
if(menuItems[i].pathname == window.location.pathname) {
|
if(menuItems[i].pathname == window.location.pathname) {
|
||||||
menuItems[i].classList.add('active');
|
menuItems[i].classList.add('active')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dropdownItems = document.getElementsByClassName('dropdown-item');
|
const dropdownItems = document.getElementsByClassName('dropdown-item')
|
||||||
for(let i = 0; i< dropdownItems.length; i++) {
|
for(let i = 0; i< dropdownItems.length; i++) {
|
||||||
if(dropdownItems[i].pathname == window.location.pathname) {
|
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||||
dropdownItems[i].classList.add('active');
|
dropdownItems[i].classList.add('active')
|
||||||
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active');
|
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// General Post Method Form Processing Script
|
// General Post Method Form Processing Script
|
||||||
$('form.form-post').submit(function(event) {
|
$('form.form-post').submit(function(event) {
|
||||||
|
|
||||||
var $form = $(this);
|
var $form = $(this)
|
||||||
var data = $form.serialize();
|
var data = $form.serialize()
|
||||||
var url = $(this).prop('action');
|
var url = $(this).prop('action')
|
||||||
var rel_success = $(this).data('rel-success');
|
var rel_success = $(this).data('rel-success')
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: url,
|
url: url,
|
||||||
@ -28,25 +28,25 @@ $('form.form-post').submit(function(event) {
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
if (response.redirect_to) {
|
if (response.redirect_to) {
|
||||||
window.location.href = response.redirect_to;
|
window.location.href = response.redirect_to
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
window.location.href = rel_success;
|
window.location.href = rel_success
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function(response) {
|
error: function(response) {
|
||||||
error_response(response);
|
error_response(response)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
// Form Upload Questions - Special case, needs to handle files.
|
// Form Upload Questions - Special case, needs to handle files.
|
||||||
$('form[name=form-upload-questions]').submit(function(event) {
|
$('form[name=form-upload-questions]').submit(function(event) {
|
||||||
|
|
||||||
var $form = $(this);
|
var $form = $(this)
|
||||||
var data = new FormData($form[0]);
|
var data = new FormData($form[0])
|
||||||
var file = $('input[name=data_file]')[0].files[0]
|
var file = $('input[name=data_file]')[0].files[0]
|
||||||
data.append('file', file)
|
data.append('file', file)
|
||||||
|
|
||||||
@ -57,21 +57,21 @@ $('form[name=form-upload-questions]').submit(function(event) {
|
|||||||
processData: false,
|
processData: false,
|
||||||
contentType: false,
|
contentType: false,
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
window.location.reload();
|
window.location.reload()
|
||||||
},
|
},
|
||||||
error: function(response) {
|
error: function(response) {
|
||||||
error_response(response);
|
error_response(response)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
// Edit and Delete Test Button Handlers
|
// Edit and Delete Test Button Handlers
|
||||||
$('.test-action').click(function(event) {
|
$('.test-action').click(function(event) {
|
||||||
|
|
||||||
let id = $(this).data('id');
|
let id = $(this).data('id')
|
||||||
let action = $(this).data('action');
|
let action = $(this).data('action')
|
||||||
|
|
||||||
if (action == 'delete' || action == 'start' || action == 'end') {
|
if (action == 'delete' || action == 'start' || action == 'end') {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@ -80,25 +80,38 @@ $('.test-action').click(function(event) {
|
|||||||
data: JSON.stringify({'id': id, 'action': action}),
|
data: JSON.stringify({'id': id, 'action': action}),
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
window.location.href = '/admin/tests/';
|
window.location.href = '/admin/tests/'
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
error_response(response);
|
error_response(response)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
} else if (action == 'edit') {
|
} else if (action == 'edit') {
|
||||||
window.location.href = `/admin/test/${id}/`
|
window.location.href = `/admin/test/${id}/`
|
||||||
|
} else if (action == 'analyse') {
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/analysis/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id, 'class': 'test'}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = response
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
// Edit Dataset Button Handlers
|
// Edit Dataset Button Handlers
|
||||||
$('.edit-question-dataset').click(function(event) {
|
$('.edit-question-dataset').click(function(event) {
|
||||||
|
|
||||||
var id = $(this).data('id');
|
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 ) {
|
||||||
if (action == 'delete') {
|
if (action == 'delete') {
|
||||||
@ -111,25 +124,40 @@ $('.edit-question-dataset').click(function(event) {
|
|||||||
}),
|
}),
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
window.location.reload();
|
window.location.reload()
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
error_response(response);
|
error_response(response)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
} else if (action == 'edit') {
|
} else if (action == 'edit') {
|
||||||
window.location.href = `/admin/editor/${id}/`
|
window.location.href = `/admin/editor/${id}/`
|
||||||
|
} else if (action == 'view') {
|
||||||
|
window.location.href = `/admin/view/${id}`
|
||||||
} else if (action == 'download') {
|
} else if (action == 'download') {
|
||||||
window.location.href = `/admin/settings/questions/download/${id}/`
|
window.location.href = `/admin/settings/questions/download/${id}/`
|
||||||
|
} else if (action == 'analyse') {
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/analysis/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id, 'class': 'dataset'}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = response
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
function error_response(response) {
|
function error_response(response) {
|
||||||
|
|
||||||
const $alert = $("#alert-box");
|
const $alert = $("#alert-box")
|
||||||
$alert.html('');
|
$alert.html('')
|
||||||
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||||
$alert.html(`
|
$alert.html(`
|
||||||
@ -138,18 +166,18 @@ function error_response(response) {
|
|||||||
${response.responseJSON.error}
|
${response.responseJSON.error}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`)
|
||||||
} else if (response.responseJSON.error instanceof Array) {
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
var output = ''
|
var output = ''
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
output += `
|
output += `
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
${response.responseJSON.error[i]}
|
${response.responseJSON.error[i]}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`
|
||||||
$alert.html(output);
|
$alert.html(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,20 +195,20 @@ $('#dismiss-cookie-alert').click(function(event){
|
|||||||
},
|
},
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
success: function(response){
|
success: function(response){
|
||||||
console.log(response);
|
console.log(response)
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
console.log(response);
|
console.log(response)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Script for Result Actions
|
// Script for Result Actions
|
||||||
$('.result-action-buttons').click(function(event){
|
$('.result-action-buttons').click(function(event){
|
||||||
|
|
||||||
var id = $(this).data('id');
|
var id = $(this).data('id')
|
||||||
|
|
||||||
if ($(this).data('result-action') == 'generate') {
|
if ($(this).data('result-action') == 'generate') {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@ -190,13 +218,13 @@ $('.result-action-buttons').click(function(event){
|
|||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
dataType: 'html',
|
dataType: 'html',
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
var display_window = window.open();
|
var display_window = window.open()
|
||||||
display_window.document.write(response);
|
display_window.document.write(response)
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
error_response(response);
|
error_response(response)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
var action = $(this).data('result-action')
|
var action = $(this).data('result-action')
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@ -206,23 +234,23 @@ $('.result-action-buttons').click(function(event){
|
|||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
if (action == 'delete') {
|
if (action == 'delete') {
|
||||||
window.location.href = '/admin/results/';
|
window.location.href = '/admin/results/'
|
||||||
} else window.location.reload();
|
} else window.location.reload()
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
error_response(response);
|
error_response(response)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
// Script for Deleting Time Adjustment
|
// Script for Deleting Time Adjustment
|
||||||
$('.adjustment-delete').click(function(event){
|
$('.adjustment-delete').click(function(event){
|
||||||
|
|
||||||
var user_code = $(this).data('user_code');
|
var user_code = $(this).data('user_code')
|
||||||
var location = window.location.href;
|
var location = window.location.href
|
||||||
location = location.replace('#', '')
|
location = location.replace('#', '')
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@ -231,12 +259,19 @@ $('.adjustment-delete').click(function(event){
|
|||||||
data: JSON.stringify({'user_code': user_code}),
|
data: JSON.stringify({'user_code': user_code}),
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
window.location.reload();
|
window.location.reload()
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
error_response(response);
|
error_response(response)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
|
// Detailed Results view questions
|
||||||
|
$('.view-full-questions').click(function(event) {
|
||||||
|
var dataset = $(this).data('dataset')
|
||||||
|
window.open(`/admin/view/${dataset}`, '_blank')
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
@ -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>
|
||||||
@ -50,7 +50,7 @@
|
|||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">Start Time</h5>
|
<h5 class="mb-1">Start Time</h5>
|
||||||
</div>
|
</div>
|
||||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
|
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
|
||||||
</li>
|
</li>
|
||||||
<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">
|
||||||
@ -59,7 +59,7 @@
|
|||||||
<span class="badge bg-danger">Late</span>
|
<span class="badge bg-danger">Late</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
|
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') if entry.end_time else None }}
|
||||||
</li>
|
</li>
|
||||||
<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">
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -1,6 +1,6 @@
|
|||||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||||
<button
|
<button
|
||||||
class="navbar-toggler"
|
class="navbar-toggler"
|
||||||
type="button"
|
type="button"
|
||||||
@ -20,8 +20,28 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<li class="nav-item" id="nav-results">
|
<li class="nav-item dropdown" id="nav-results">
|
||||||
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-results"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._view_entries') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-results"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="nav-tests">
|
<li class="nav-item dropdown" id="nav-tests">
|
||||||
<a
|
<a
|
||||||
@ -36,7 +56,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul
|
<ul
|
||||||
class="dropdown-menu"
|
class="dropdown-menu"
|
||||||
aria-labelledby="dropdown-settings"
|
aria-labelledby="dropdown-tests"
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||||
@ -58,7 +78,7 @@
|
|||||||
<li class="nav-item dropdown" id="nav-settings">
|
<li class="nav-item dropdown" id="nav-settings">
|
||||||
<a
|
<a
|
||||||
class="nav-link dropdown-toggle"
|
class="nav-link dropdown-toggle"
|
||||||
id="dropdown-account"
|
id="dropdown-settings"
|
||||||
role="button"
|
role="button"
|
||||||
href="{{ url_for('admin._settings') }}"
|
href="{{ url_for('admin._settings') }}"
|
||||||
data-bs-toggle="dropdown"
|
data-bs-toggle="dropdown"
|
||||||
@ -77,10 +97,13 @@
|
|||||||
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</a>
|
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ test.end_date.strftime('%d %b %Y') }}
|
{{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -72,10 +72,14 @@
|
|||||||
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
|
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ result.end_time.strftime('%d %b %Y %H:%M') }}
|
{{ result.end_time.strftime('%d %b %Y %H:%M') if result.end_time else None }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
{% if result.result %}
|
||||||
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
||||||
|
{% else %}
|
||||||
|
Incomplete
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -117,7 +121,7 @@
|
|||||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ test.end_date.strftime('%d %b %Y') }}
|
{{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -138,7 +142,7 @@
|
|||||||
<div class="card m-3">
|
<div class="card m-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Help</h5>
|
<h5 class="card-title">Help</h5>
|
||||||
<p class="card-text">This web app was developed by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
|
<p class="card-text">This web app was developed and is maintained by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
|
||||||
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
|
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
@ -49,7 +49,7 @@
|
|||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">Start Time</h5>
|
<h5 class="mb-1">Start Time</h5>
|
||||||
</div>
|
</div>
|
||||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
|
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="list-group-item list-group-item-action">
|
<li class="list-group-item list-group-item-action">
|
||||||
@ -108,7 +108,7 @@
|
|||||||
{% for tag, scores in entry.result.tags.items() %}
|
{% for tag, scores in entry.result.tags.items() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ tag }}
|
{{ tag|safe }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ scores.scored }}
|
{{ scores.scored }}
|
||||||
@ -131,6 +131,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div id="by-question-breakdown" class="accordion-collapse collapse" aria-labelledby="by-question" data-bs-parent="#results-breakdown">
|
<div id="by-question-breakdown" class="accordion-collapse collapse" aria-labelledby="by-question" data-bs-parent="#results-breakdown">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
|
<a class="view-full-questions" data-dataset="{{ entry.test.dataset.id }}">View Questions</a>
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -146,7 +147,7 @@
|
|||||||
{% for question, answer in entry.answers.items() %}
|
{% for question, answer in entry.answers.items() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ question }}
|
{{ question|int + 1 }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ answers[question|int][answer|int] }}
|
{{ answers[question|int][answer|int] }}
|
||||||
|
@ -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>
|
||||||
@ -54,7 +54,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if entry.end_time %}
|
{% if entry.end_time %}
|
||||||
{{ entry.end_time.strftime('%d %b %Y') }}
|
{{ entry.end_time.strftime('%Y-%m-%d %H:%M') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -43,7 +43,7 @@
|
|||||||
{{ element.get_name() }}
|
{{ element.get_name() }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ element.date.strftime('%d %b %Y %H:%M') }}
|
{{ element.date.strftime('%Y-%m-%d %H:%M') }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ element.creator.get_username() }}
|
{{ element.creator.get_username() }}
|
||||||
@ -52,33 +52,51 @@
|
|||||||
{{ element.tests|length }}
|
{{ element.tests|length }}
|
||||||
</td>
|
</td>
|
||||||
<td class="row-actions">
|
<td class="row-actions">
|
||||||
|
<a
|
||||||
|
href="javascript:void(0)"
|
||||||
|
class="btn btn-success edit-question-dataset {% if not element.entries %} disabled {% endif %}"
|
||||||
|
data-id="{{ element.id }}"
|
||||||
|
data-action="analyse"
|
||||||
|
title="Analyse Answers"
|
||||||
|
>
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="javascript:void(0)"
|
href="javascript:void(0)"
|
||||||
class="btn btn-primary edit-question-dataset"
|
class="btn btn-primary edit-question-dataset"
|
||||||
data-id="{{ element.id }}"
|
data-id="{{ element.id }}"
|
||||||
data-action="download"
|
data-action="download"
|
||||||
title="Download Dataset"
|
title="Download Questions"
|
||||||
>
|
>
|
||||||
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
|
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
|
||||||
</button>
|
</a>
|
||||||
|
<a
|
||||||
|
href="javascript:void(0)"
|
||||||
|
class="btn btn-primary edit-question-dataset"
|
||||||
|
data-id="{{ element.id }}"
|
||||||
|
data-action="view"
|
||||||
|
title="View Questions"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="javascript:void(0)"
|
href="javascript:void(0)"
|
||||||
class="btn btn-primary edit-question-dataset"
|
class="btn btn-primary edit-question-dataset"
|
||||||
data-id="{{ element.id }}"
|
data-id="{{ element.id }}"
|
||||||
data-action="edit"
|
data-action="edit"
|
||||||
title="Edit Dataset"
|
title="Edit Questions"
|
||||||
>
|
>
|
||||||
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
<i class="bi bi-pencil-fill button-icon"></i>
|
||||||
</button>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="javascript:void(0)"
|
href="javascript:void(0)"
|
||||||
class="btn btn-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
|
class="btn btn-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
|
||||||
data-id="{{ element.id }}"
|
data-id="{{ element.id }}"
|
||||||
data-action="delete"
|
data-action="delete"
|
||||||
title="Delete Dataset"
|
title="Delete Questions"
|
||||||
>
|
>
|
||||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||||
</button>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">Dataset</h5>
|
<h5 class="mb-1">Dataset</h5>
|
||||||
</div>
|
</div>
|
||||||
{{ test.dataset.date.strftime('%Y%m%d%H%M%S') }}
|
<a href="{{ url_for('view._view_console', id=test.dataset.id) }}">{{ test.dataset.get_name() }}</a>
|
||||||
</li>
|
</li>
|
||||||
<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">
|
||||||
@ -32,13 +32,13 @@
|
|||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">Start Date</h5>
|
<h5 class="mb-1">Start Date</h5>
|
||||||
</div>
|
</div>
|
||||||
{{ test.start_date.strftime('%d %b %Y %H:%M') }}
|
{{ test.start_date.strftime('%d %b %Y %H:%M') if test.start_date else None }}
|
||||||
</li>
|
</li>
|
||||||
<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">Expiry Date</h5>
|
<h5 class="mb-1">Expiry Date</h5>
|
||||||
</div>
|
</div>
|
||||||
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
|
{{ test.end_date.strftime('%d %b %Y %H:%M') if test.end_date else None }}
|
||||||
</li>
|
</li>
|
||||||
<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">
|
||||||
@ -71,7 +71,7 @@
|
|||||||
{% for entry in test.entries %}
|
{% for entry in test.entries %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('admin._view_entry', id=entry) }}" >Entry {{ loop.index }}</a>
|
<a href="{{ url_for('admin._view_entry', id=entry.id) }}" >Entry {{ loop.index }}</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -162,7 +162,7 @@
|
|||||||
{% include "admin/components/client-alerts.html" %}
|
{% include "admin/components/client-alerts.html" %}
|
||||||
</div>
|
</div>
|
||||||
<div class="container justify-content-center">
|
<div class="container justify-content-center">
|
||||||
<div class="row">
|
<div class="my-3 row">
|
||||||
{% if test.start_date <= now %}
|
{% if test.start_date <= now %}
|
||||||
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
|
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
|
||||||
<i class="bi bi-hourglass-bottom button-icon"></i>
|
<i class="bi bi-hourglass-bottom button-icon"></i>
|
||||||
@ -174,6 +174,16 @@
|
|||||||
Start Exam
|
Start Exam
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
|
||||||
|
data-id="{{test.id}}"
|
||||||
|
title="Analyse Exam"
|
||||||
|
data-action="analyse"
|
||||||
|
>
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
Analyse Exam
|
||||||
|
</a>
|
||||||
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
|
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
|
||||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||||
Delete Exam
|
Delete Exam
|
||||||
|
@ -33,13 +33,13 @@
|
|||||||
{% for test in tests %}
|
{% for test in tests %}
|
||||||
<tr class="table-row">
|
<tr class="table-row">
|
||||||
<td>
|
<td>
|
||||||
{{ test.start_date.strftime('%d %b %y %H:%M') }}
|
{{ test.start_date.strftime('%Y-%m-%d %H:%M') }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ test.get_code() }}
|
{{ test.get_code() }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
|
{{ test.end_date.strftime('%Y-%m-%d %H:%M') }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if test.time_limit == None -%}
|
{% if test.time_limit == None -%}
|
||||||
@ -58,6 +58,15 @@
|
|||||||
{{ test.entries|length }}
|
{{ test.entries|length }}
|
||||||
</td>
|
</td>
|
||||||
<td class="row-actions">
|
<td class="row-actions">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
|
||||||
|
data-id="{{test.id}}"
|
||||||
|
title="Analyse Exam"
|
||||||
|
data-action="analyse"
|
||||||
|
>
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="btn btn-primary test-action"
|
class="btn btn-primary test-action"
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
|
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
|
||||||
from ..models import Dataset, Entry, Test, User
|
from ..models import Dataset, Entry, Test, User
|
||||||
from ..tools.auth import disable_if_logged_in, require_account_creation
|
from ..tools.auth import disable_if_logged_in, require_account_creation
|
||||||
|
from ..tools.data import check_dataset_exists, check_is_json, validate_json
|
||||||
from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
|
from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
|
||||||
from ..tools.data import check_is_json, validate_json
|
from ..tools.logs import write
|
||||||
from ..tools.test import answer_options, get_correct_answers
|
from ..tools.test import answer_options, get_correct_answers
|
||||||
|
|
||||||
from flask import abort, Blueprint, jsonify, render_template, redirect, request, send_file, session
|
from flask import abort, Blueprint, jsonify, render_template, request, send_file, session
|
||||||
from flask.helpers import flash, url_for
|
from flask.helpers import abort, flash, redirect, 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, MINYEAR, timedelta
|
||||||
from json import loads
|
from json import loads
|
||||||
from os import path
|
from os import path
|
||||||
import secrets
|
import secrets
|
||||||
@ -26,21 +27,29 @@ admin = Blueprint(
|
|||||||
@admin.route('/dashboard/')
|
@admin.route('/dashboard/')
|
||||||
@login_required
|
@login_required
|
||||||
def _home():
|
def _home():
|
||||||
|
try:
|
||||||
tests = Test.query.all()
|
tests = Test.query.all()
|
||||||
results = Entry.query.all()
|
results = Entry.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
|
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
|
||||||
current_tests.sort(key= lambda x: x.end_date, reverse=True)
|
current_tests.sort(key= lambda x: x.end_date or datetime(MINYEAR,1,1), reverse=True)
|
||||||
upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()]
|
upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()]
|
||||||
upcoming_tests.sort(key= lambda x: x.start_date)
|
upcoming_tests.sort(key= lambda x: x.start_date or datetime(MINYEAR,1,1))
|
||||||
recent_results = [result for result in results if not result.status == 'started' ]
|
recent_results = [result for result in results if not result.status == 'started' ]
|
||||||
recent_results.sort(key= lambda x: x.end_time, reverse=True)
|
recent_results.sort(key= lambda x: x.end_time or datetime(MINYEAR,1,1), reverse=True)
|
||||||
return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results)
|
return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results)
|
||||||
|
|
||||||
@admin.route('/settings/')
|
@admin.route('/settings/')
|
||||||
@login_required
|
@login_required
|
||||||
def _settings():
|
def _settings():
|
||||||
|
try:
|
||||||
users = User.query.all()
|
users = User.query.all()
|
||||||
datasets = Dataset.query.all()
|
datasets = Dataset.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
|
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
|
||||||
|
|
||||||
@admin.route('/login/', methods=['GET','POST'])
|
@admin.route('/login/', methods=['GET','POST'])
|
||||||
@ -50,7 +59,10 @@ def _login():
|
|||||||
form = Login()
|
form = Login()
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
users = User.query.all()
|
try: users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
user = None
|
user = None
|
||||||
for _user in users:
|
for _user in users:
|
||||||
if _user.get_username() == request.form.get('username').lower():
|
if _user.get_username() == request.form.get('username').lower():
|
||||||
@ -91,7 +103,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():
|
||||||
@ -99,7 +111,10 @@ def _reset():
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
user = None
|
user = None
|
||||||
users = User.query.all()
|
try: users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
for _user in users:
|
for _user in users:
|
||||||
if _user.get_username() == request.form.get('username'):
|
if _user.get_username() == request.form.get('username'):
|
||||||
user = _user
|
user = _user
|
||||||
@ -111,13 +126,17 @@ def _reset():
|
|||||||
|
|
||||||
token = request.args.get('token')
|
token = request.args.get('token')
|
||||||
if token:
|
if token:
|
||||||
user = User.query.filter_by(reset_token=token).first()
|
try: user = User.query.filter_by(reset_token=token).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not user: return redirect(url_for('admin._reset'))
|
if not user: return redirect(url_for('admin._reset'))
|
||||||
verification_token = user.verification_token
|
verification_token = user.verification_token
|
||||||
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)
|
||||||
@ -126,8 +145,11 @@ 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()
|
try: user = User.query.filter_by(id=user).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
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()
|
||||||
flash('Your password has been reset.', 'success')
|
flash('Your password has been reset.', 'success')
|
||||||
@ -138,7 +160,10 @@ def _update_password():
|
|||||||
@login_required
|
@login_required
|
||||||
def _users():
|
def _users():
|
||||||
form = CreateUser()
|
form = CreateUser()
|
||||||
users = User.query.all()
|
try: users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
password = request.form.get('password')
|
password = request.form.get('password')
|
||||||
@ -155,7 +180,10 @@ def _users():
|
|||||||
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
|
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def _delete_user(id:str):
|
def _delete_user(id:str):
|
||||||
user = User.query.filter_by(id=id).first()
|
try: user = User.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
form = DeleteUser()
|
form = DeleteUser()
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||||
@ -179,12 +207,15 @@ def _delete_user(id:str):
|
|||||||
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
|
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def _update_user(id:str):
|
def _update_user(id:str):
|
||||||
user = User.query.filter_by(id=id).first()
|
try: user = User.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
form = UpdateUser()
|
form = UpdateUser()
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
if not user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
if not current_user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
||||||
success, message = user.update(
|
success, message = user.update(
|
||||||
password = request.form.get('password'),
|
password = request.form.get('password'),
|
||||||
email = request.form.get('email'),
|
email = request.form.get('email'),
|
||||||
@ -220,8 +251,10 @@ def _questions():
|
|||||||
if success: return jsonify({'success': message}), 200
|
if success: return jsonify({'success': message}), 200
|
||||||
return jsonify({'error': message}), 400
|
return jsonify({'error': message}), 400
|
||||||
return send_errors_to_client(form=form)
|
return send_errors_to_client(form=form)
|
||||||
|
try: data = Dataset.query.all()
|
||||||
data = Dataset.query.all()
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
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/delete/', methods=['POST'])
|
@admin.route('/settings/questions/delete/', methods=['POST'])
|
||||||
@ -230,7 +263,10 @@ def _edit_questions():
|
|||||||
id = request.get_json()['id']
|
id = request.get_json()['id']
|
||||||
action = request.get_json()['action']
|
action = request.get_json()['action']
|
||||||
if not action == '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()
|
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if action == 'delete': success, message = dataset.delete()
|
if action == 'delete': success, message = dataset.delete()
|
||||||
if success: return jsonify({'success': message}), 200
|
if success: return jsonify({'success': message}), 200
|
||||||
return jsonify({'error': message}), 400
|
return jsonify({'error': message}), 400
|
||||||
@ -238,26 +274,31 @@ def _edit_questions():
|
|||||||
@admin.route('/settings/questions/download/<string:id>/')
|
@admin.route('/settings/questions/download/<string:id>/')
|
||||||
@login_required
|
@login_required
|
||||||
def _download(id:str):
|
def _download(id:str):
|
||||||
dataset = Dataset.query.filter_by(id=id).first()
|
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not dataset: return abort(404)
|
if not dataset: return abort(404)
|
||||||
data_path = path.abspath(dataset.get_file())
|
data_path = path.abspath(dataset.get_file())
|
||||||
return send_file(data_path, as_attachment=True, attachment_filename=f'{dataset.get_name()}.json')
|
return send_file(data_path, as_attachment=True, download_name=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
|
||||||
|
@check_dataset_exists
|
||||||
def _tests(filter:str=None):
|
def _tests(filter:str=None):
|
||||||
datasets = Dataset.query.all()
|
|
||||||
tests = None
|
tests = None
|
||||||
_tests = Test.query.all()
|
try: _tests = Test.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
form = None
|
form = None
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
if not datasets:
|
|
||||||
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
|
|
||||||
return redirect(url_for('admin._questions'))
|
|
||||||
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
if filter 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'
|
||||||
@ -267,7 +308,7 @@ def _tests(filter:str=None):
|
|||||||
if filter in [None, '', 'active']:
|
if filter in [None, '', 'active']:
|
||||||
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
|
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
|
||||||
display_title = 'Active Exams'
|
display_title = 'Active Exams'
|
||||||
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
|
error_none = 'There are no exams that are currently active. You can create one using the Create Exam form.'
|
||||||
if filter == 'expired':
|
if filter == 'expired':
|
||||||
tests = [ test for test in _tests if test.end_date < now ]
|
tests = [ test for test in _tests if test.end_date < now ]
|
||||||
display_title = 'Expired Exams'
|
display_title = 'Expired Exams'
|
||||||
@ -296,7 +337,10 @@ def _create_test():
|
|||||||
new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%dT%H:%M')
|
new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%dT%H:%M')
|
||||||
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
|
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
|
||||||
dataset = request.form.get('dataset')
|
dataset = request.form.get('dataset')
|
||||||
new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
try: new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
success, message = new_test.create()
|
success, message = new_test.create()
|
||||||
if success:
|
if success:
|
||||||
flash(message=message, category='success')
|
flash(message=message, category='success')
|
||||||
@ -310,7 +354,10 @@ def _edit_test():
|
|||||||
id = request.get_json()['id']
|
id = request.get_json()['id']
|
||||||
action = request.get_json()['action']
|
action = request.get_json()['action']
|
||||||
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
|
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
|
||||||
test = Test.query.filter_by(id=id).first()
|
try: test = Test.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||||
if action == 'delete': success, message = test.delete()
|
if action == 'delete': success, message = test.delete()
|
||||||
if action == 'start': success, message = test.start()
|
if action == 'start': success, message = test.start()
|
||||||
@ -324,7 +371,10 @@ def _edit_test():
|
|||||||
@login_required
|
@login_required
|
||||||
def _view_test(id:str=None):
|
def _view_test(id:str=None):
|
||||||
form = AddTimeAdjustment()
|
form = AddTimeAdjustment()
|
||||||
test = Test.query.filter_by(id=id).first()
|
try: test = Test.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
@ -341,7 +391,10 @@ def _view_test(id:str=None):
|
|||||||
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
|
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def _delete_adjustment(id:str=None):
|
def _delete_adjustment(id:str=None):
|
||||||
test = Test.query.filter_by(id=id).first()
|
try: test = Test.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||||
user_code = request.get_json()['user_code'].lower()
|
user_code = request.get_json()['user_code'].lower()
|
||||||
success, message = test.remove_adjustment(user_code)
|
success, message = test.remove_adjustment(user_code)
|
||||||
@ -351,13 +404,19 @@ def _delete_adjustment(id:str=None):
|
|||||||
@admin.route('/results/')
|
@admin.route('/results/')
|
||||||
@login_required
|
@login_required
|
||||||
def _view_entries():
|
def _view_entries():
|
||||||
entries = Entry.query.all()
|
try: entries = Entry.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
return render_template('/admin/results.html', entries = entries)
|
return render_template('/admin/results.html', entries = entries)
|
||||||
|
|
||||||
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def _view_entry(id:str=None):
|
def _view_entry(id:str=None):
|
||||||
entry = Entry.query.filter_by(id=id).first()
|
try: entry = Entry.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
action = request.get_json()['action']
|
action = request.get_json()['action']
|
||||||
@ -375,10 +434,7 @@ def _view_entry(id:str=None):
|
|||||||
flash('Invalid entry ID.', 'error')
|
flash('Invalid entry ID.', 'error')
|
||||||
return redirect(url_for('admin._view_entries'))
|
return redirect(url_for('admin._view_entries'))
|
||||||
test = entry.test
|
test = entry.test
|
||||||
dataset = test.dataset
|
data = test.dataset.get_data()
|
||||||
dataset_path = dataset.get_file()
|
|
||||||
with open(dataset_path, 'r') as _dataset:
|
|
||||||
data = loads(_dataset.read())
|
|
||||||
correct = get_correct_answers(dataset=data)
|
correct = get_correct_answers(dataset=data)
|
||||||
answers = answer_options(dataset=data)
|
answers = answer_options(dataset=data)
|
||||||
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
|
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
|
||||||
@ -388,6 +444,9 @@ def _view_entry(id:str=None):
|
|||||||
def _generate_certificate():
|
def _generate_certificate():
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
id = request.get_json()['id']
|
id = request.get_json()['id']
|
||||||
entry = Entry.query.filter_by(id=id).first()
|
try: entry = Entry.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
return render_template('/admin/components/certificate.html', entry = entry)
|
return render_template('/admin/components/certificate.html', entry = entry)
|
0
ref-test/app/analysis/__init__.py
Normal file
0
ref-test/app/analysis/__init__.py
Normal file
8
ref-test/app/analysis/static/css/analysis.css
Normal file
8
ref-test/app/analysis/static/css/analysis.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#alert-box {
|
||||||
|
margin: 30px auto;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-percentage::after {
|
||||||
|
content: '%';
|
||||||
|
}
|
260
ref-test/app/analysis/static/css/style.css
Normal file
260
ref-test/app/analysis/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
body {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
background-color: lightgray;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
-ms-flex-align: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-display {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-heading {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input,
|
||||||
|
.form-label-group label {
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0; /* Override default `<label>` margin */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #495057;
|
||||||
|
cursor: text; /* Match the input under the label */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: all .1s ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0%;
|
||||||
|
border-bottom: 2px solid #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:active, .form-label-group input:focus {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-webkit-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-moz-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:not(:placeholder-shown) {
|
||||||
|
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||||
|
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||||
|
padding-top: calc(var(--input-padding-y) / 3);
|
||||||
|
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
transform: scale(1.5);
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-forgot-password {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button button, .form-submission-button a {
|
||||||
|
margin: 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTables_wrapper .dt-buttons {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
float:none;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button, .row-actions a {
|
||||||
|
margin: 0px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cookie-alert {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dismiss-cookie-alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-db-empty {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: 14pt;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input, .form-select-input {
|
||||||
|
position: relative;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input input,
|
||||||
|
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
font-size: 16pt;
|
||||||
|
width: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker::-webkit-calendar-picker-indicator {
|
||||||
|
border: 1px;
|
||||||
|
border-color: gray;
|
||||||
|
border-radius: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input label, .form-select-input label {
|
||||||
|
/* position: absolute; */
|
||||||
|
/* top: 0;
|
||||||
|
left: 0; */
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0; /* Override default `<label>` margin */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #495057;
|
||||||
|
cursor: text; /* Match the input under the label */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: all .1s ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-upload {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-action-buttons, .test-action {
|
||||||
|
margin: 5px auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Change Autocomplete styles in Chrome*/
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
textarea:-webkit-autofill,
|
||||||
|
textarea:-webkit-autofill:hover,
|
||||||
|
textarea:-webkit-autofill:focus,
|
||||||
|
select:-webkit-autofill,
|
||||||
|
select:-webkit-autofill:hover,
|
||||||
|
select:-webkit-autofill:focus {
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for Edge
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@supports (-ms-ime-align: auto) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for IE
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
27
ref-test/app/analysis/static/js/analysis.js
Normal file
27
ref-test/app/analysis/static/js/analysis.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// Analyse Button Listener
|
||||||
|
$('.button-analyse').click(function(event) {
|
||||||
|
|
||||||
|
let buttonClass = $(this).data('class')
|
||||||
|
let id = null
|
||||||
|
|
||||||
|
if (buttonClass == 'test' ) {
|
||||||
|
id = $('#select-test').children('option:selected').val()
|
||||||
|
} else if (buttonClass == 'dataset' ) {
|
||||||
|
id = $('#select-dataset').children('option:selected').val()
|
||||||
|
}
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/analysis/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id, 'class': buttonClass}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = response
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
2
ref-test/app/analysis/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/app/analysis/static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
115
ref-test/app/analysis/static/js/script.js
Normal file
115
ref-test/app/analysis/static/js/script.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
// Menu Highlight Scripts
|
||||||
|
const menuItems = document.getElementsByClassName('nav-link')
|
||||||
|
for(let i = 0; i < menuItems.length; i++) {
|
||||||
|
if(menuItems[i].pathname == window.location.pathname) {
|
||||||
|
menuItems[i].classList.add('active')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dropdownItems = document.getElementsByClassName('dropdown-item')
|
||||||
|
for(let i = 0; i< dropdownItems.length; i++) {
|
||||||
|
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||||
|
dropdownItems[i].classList.add('active')
|
||||||
|
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General Post Method Form Processing Script
|
||||||
|
$('form.form-post').submit(function(event) {
|
||||||
|
|
||||||
|
var $form = $(this)
|
||||||
|
var data = $form.serialize()
|
||||||
|
var url = $(this).prop('action')
|
||||||
|
var rel_success = $(this).data('rel-success')
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response) {
|
||||||
|
if (response.redirect_to) {
|
||||||
|
window.location.href = response.redirect_to
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
window.location.href = rel_success
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
function error_response(response) {
|
||||||
|
|
||||||
|
const $alert = $("#alert-box")
|
||||||
|
$alert.html('')
|
||||||
|
|
||||||
|
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||||
|
$alert.html(`
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
|
var output = ''
|
||||||
|
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
|
output += `
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error[i]}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
$alert.html(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$alert.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss Cookie Alert
|
||||||
|
$('#dismiss-cookie-alert').click(function(event){
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/cookies/',
|
||||||
|
type: 'POST',
|
||||||
|
data: {
|
||||||
|
time: Date.now()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
console.log(response)
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
console.log(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create New Dataset
|
||||||
|
$('.create-new-dataset').click(function(event){
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/editor/new/',
|
||||||
|
type: 'POST',
|
||||||
|
data: {
|
||||||
|
time: Date.now()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
if (response.redirect_to) {
|
||||||
|
window.location.href = response.redirect_to
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
console.log(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
198
ref-test/app/analysis/templates/analysis/analysis.html
Normal file
198
ref-test/app/analysis/templates/analysis/analysis.html
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
{% extends "analysis/components/datatable.html" %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/analysis.css') }}"
|
||||||
|
/>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Analysis by {{ type[0]|upper }}{{ type[1:] }}</h1>
|
||||||
|
<div class="container">
|
||||||
|
<p class="lead">
|
||||||
|
The analysis section displays statistics for all test results as well as answers to individual questions.
|
||||||
|
Analysis reports can be generated per exam or per question dataset to identify common mistakes or patterns in answers.
|
||||||
|
</p>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">
|
||||||
|
{% if type == 'exam' %}
|
||||||
|
Exam Code
|
||||||
|
{% elif type == 'dataset' %}
|
||||||
|
Dataset Name
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ subject }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Total Entries</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ analysis.entries }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Passed</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ analysis.grades.merit + analysis.grades.pass }} ({{ ((analysis.grades.merit + analysis.grades.pass)*100/analysis.entries)|round(2) }} %)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="badge rounded-pill progress-bar-striped bg-success">Merit: {{ analysis.grades.merit }}</span> <span class="badge rounded-pill bg-primary progress-bar-striped">Pass: {{ analysis.grades.pass }}</span> <span class="badge rounded-pill progress-bar-striped bg-danger">Fail: {{ analysis.grades.fail }}</span>
|
||||||
|
<div class="my-1 progress">
|
||||||
|
<div class="progress-bar progress-bar-striped bg-success" role="progressbar" style="width: {{ (analysis.grades.merit*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.merit }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.merit*100/analysis.entries)|round(2) }} %</div>
|
||||||
|
<div class="progress-bar progress-bar-striped" role="progressbar" style="width: {{ (analysis.grades.pass*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.pass }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.pass*100/analysis.entries)|round(2) }} %</div>
|
||||||
|
<div class="progress-bar progress-bar-striped bg-danger" role="progressbar" style="width: {{ (analysis.grades.fail*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.fail }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.fail*100/analysis.entries)|round(2) }} %</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Mean Score</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ analysis.scores.mean|round(2) }} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Standard Deviation</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{% if analysis.scores.stdev %}
|
||||||
|
{{ analysis.scores.stdev|round(2) }}
|
||||||
|
{% else %}
|
||||||
|
{{ None }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Median Score</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ analysis.scores.median|round(2) }} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if type == 'exam' %}
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Dataset Name</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ dataset.get_name() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<table id="analysis-table" class="table table-striped" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<th data-priority="1">
|
||||||
|
Question
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Percent Correct
|
||||||
|
</th>
|
||||||
|
<th data-priority="2">
|
||||||
|
Answers
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Tags
|
||||||
|
</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for question in questions %}
|
||||||
|
<tr class="table-row">
|
||||||
|
<td>
|
||||||
|
{{ question.q_no + 1 }}
|
||||||
|
</td>
|
||||||
|
<td class="cell-percentage">
|
||||||
|
{{ ((analysis.answers[question.q_no][question.correct] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<table style="width:100%">
|
||||||
|
{% for option in question.options %}
|
||||||
|
<tr>
|
||||||
|
<td style="width:50%">
|
||||||
|
{{ option[1] }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if question.correct == option[0] %}
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar bg-danger progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
{% for tag in question.tags %}
|
||||||
|
<li>{{ tag|safe }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
const target = "{{ url_for('api._editor') }}"
|
||||||
|
const id = "{{ dataset.id }}"
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/analysis.js') }}"
|
||||||
|
></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block custom_data_script %}
|
||||||
|
<script>
|
||||||
|
console.log($('#analysis-table'))
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#analysis-table').DataTable({
|
||||||
|
'searching': true,
|
||||||
|
'columnDefs': [
|
||||||
|
{'sortable': true, 'targets': [0,1]},
|
||||||
|
{'sortable': false, 'targets': [2,3]},
|
||||||
|
{'searchable': true, 'targets': [0,2,3]}
|
||||||
|
],
|
||||||
|
'order': [[0, 'asc'], [1, 'desc']],
|
||||||
|
'buttons': [
|
||||||
|
{
|
||||||
|
extend: 'print',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 2, 3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extend: 'excel',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 2, 3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extend: 'pdf',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 2, 3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'responsive': 'true',
|
||||||
|
'colReorder': 'true',
|
||||||
|
'fixedHeader': 'true',
|
||||||
|
'searchBuilder': {
|
||||||
|
depthLimit: 2,
|
||||||
|
columns: [2, 3],
|
||||||
|
},
|
||||||
|
dom: 'BQlfrtip'
|
||||||
|
});
|
||||||
|
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||||
|
} );
|
||||||
|
$('#analysis-table').show();
|
||||||
|
$(window).trigger('resize');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/view.css') }}"
|
||||||
|
/>
|
||||||
|
{% block style %}
|
||||||
|
{% endblock %}
|
||||||
|
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||||
|
{% include "analysis/components/og-meta.html" %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
{% block navbar %}
|
||||||
|
{% include "analysis/components/navbar.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% include "analysis/components/server-alerts.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="container site-footer mt-5">
|
||||||
|
{% block footer %}
|
||||||
|
{% include "analysis/components/footer.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||||
|
<script
|
||||||
|
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||||
|
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||||
|
crossorigin="anonymous">
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||||
|
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||||
|
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
<!-- Custom js -->
|
||||||
|
<script type="text/javascript">
|
||||||
|
var csrf_token = "{{ csrf_token() }}";
|
||||||
|
|
||||||
|
$.ajaxSetup({
|
||||||
|
beforeSend: function(xhr, settings) {
|
||||||
|
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||||
|
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/analysis.js') }}"
|
||||||
|
></script>
|
||||||
|
{% block script %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block datatable_scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1 @@
|
|||||||
|
<div id="alert-box" tabindex="-1"></div>
|
@ -0,0 +1,28 @@
|
|||||||
|
{% extends "analysis/components/base.html" %}
|
||||||
|
{% block datatable_css %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
|
||||||
|
{% endblock %}
|
||||||
|
{% block datatable_scripts %}
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,2 @@
|
|||||||
|
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||||
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
|||||||
|
{% extends "analysis/components/base.html" %}
|
||||||
|
{% import "bootstrap/wtf.html" as wtf %}
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% endblock %}
|
137
ref-test/app/analysis/templates/analysis/components/navbar.html
Normal file
137
ref-test/app/analysis/templates/analysis/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbar"
|
||||||
|
aria-controls="navbar"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle Navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if not current_user.is_authenticated %}
|
||||||
|
<li class="nav-item" id="nav-login">
|
||||||
|
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item dropdown" id="nav-results">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-results"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._view_entries') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-results"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-tests">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-tests"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._tests') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Exams
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-tests"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-settings">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-settings"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._settings') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-settings"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-account">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-account"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-account"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
@ -0,0 +1,18 @@
|
|||||||
|
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||||
|
<meta property="og:locale" content="en_UK" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||||
|
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
|
||||||
|
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||||
|
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||||
|
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||||
|
<meta property="og:image:width" content="512" />
|
||||||
|
<meta property="og:image:height" content="512" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||||
|
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||||
|
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||||
|
<meta name="twitter:creator" content="@viveksantayana" />
|
||||||
|
<meta name="twitter:site" content="@viveksantayana" />
|
||||||
|
<meta name="theme-color" content="#343a40" />
|
||||||
|
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">
|
@ -0,0 +1,23 @@
|
|||||||
|
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,43 @@
|
|||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% set cookie_flash_flag = namespace(value=False) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
{% if category == "error" %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "success" %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "warning" %}
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "cookie_alert" %}
|
||||||
|
{% if not cookie_flash_flag.value %}
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<div class="d-flex justify-content-center w-100">
|
||||||
|
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set cookie_flash_flag.value = True %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
54
ref-test/app/analysis/templates/analysis/index.html
Normal file
54
ref-test/app/analysis/templates/analysis/index.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "analysis/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Analysis</h1>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Exams</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="form-select-input">
|
||||||
|
<select name="select-test" id="select-test">
|
||||||
|
{% for test in tests %}
|
||||||
|
<option value="{{ test.id }}">{{ test.get_code() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="my-3">
|
||||||
|
<a href="{{ url_for('analysis._test') }}" class="btn btn-primary button-analyse" data-class="test">
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
Analyse
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Datasets</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="form-select-input">
|
||||||
|
<select name="select-dataset" id="select-dataset">
|
||||||
|
{% for dataset in datasets %}
|
||||||
|
<option value="{{ dataset.id }}">{{ dataset.get_name() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="my-3">
|
||||||
|
<a href="{{ url_for('analysis._dataset') }}" class="btn btn-primary button-analyse" data-class="dataset">
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
Analyse
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include "analysis/components/client-alerts.html" %}
|
||||||
|
{% endblock %}
|
85
ref-test/app/analysis/views.py
Normal file
85
ref-test/app/analysis/views.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
from ..models import Dataset, Test
|
||||||
|
from ..tools.data import analyse, check_dataset_exists, check_test_exists
|
||||||
|
from ..tools.logs import write
|
||||||
|
from ..tools.data import parse_questions
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, jsonify
|
||||||
|
from flask.helpers import abort, flash, redirect, url_for
|
||||||
|
from flask_login import login_required
|
||||||
|
|
||||||
|
analysis = Blueprint(
|
||||||
|
name='analysis',
|
||||||
|
import_name=__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static'
|
||||||
|
)
|
||||||
|
|
||||||
|
@analysis.route('/', methods=['GET','POST'])
|
||||||
|
@login_required
|
||||||
|
@check_dataset_exists
|
||||||
|
@check_test_exists
|
||||||
|
def _analysis():
|
||||||
|
try:
|
||||||
|
_tests = Test.query.all()
|
||||||
|
_datasets = Dataset.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
tests = [ test for test in _tests if test.entries ]
|
||||||
|
datasets = [ dataset for dataset in _datasets if dataset.entries ]
|
||||||
|
if request.method == 'POST':
|
||||||
|
selection = request.get_json()
|
||||||
|
if selection['class'] == 'test':
|
||||||
|
try:
|
||||||
|
test = Test.query.filter_by(id=selection['id']).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not test: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
return url_for('analysis._test', id=selection['id']), 200
|
||||||
|
if selection['class'] == 'dataset':
|
||||||
|
try:
|
||||||
|
dataset = Dataset.query.filter_by(id=selection['id']).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not dataset: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
return url_for('analysis._dataset', id=selection['id']), 200
|
||||||
|
return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
return render_template('/analysis/index.html', tests=tests, datasets=datasets)
|
||||||
|
|
||||||
|
@analysis.route('/test/<string:id>')
|
||||||
|
@analysis.route('/test/')
|
||||||
|
@login_required
|
||||||
|
@check_test_exists
|
||||||
|
def _test(id:str=None):
|
||||||
|
if id in [None, '']:
|
||||||
|
flash(message='Please select a valid exam.', category='error')
|
||||||
|
return redirect(url_for('analysis._analysis'))
|
||||||
|
try:
|
||||||
|
test = Test.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not test:
|
||||||
|
flash('Invalid exam.', 'error')
|
||||||
|
return redirect(url_for('analysis._analysis'))
|
||||||
|
return render_template('/analysis/analysis.html', analysis=analyse(test), subject=test.get_code(), type='exam', dataset=test.dataset, questions=parse_questions(test.dataset.get_data()))
|
||||||
|
|
||||||
|
@analysis.route('/dataset/<string:id>')
|
||||||
|
@analysis.route('/dataset/')
|
||||||
|
@login_required
|
||||||
|
@check_dataset_exists
|
||||||
|
def _dataset(id:str=None):
|
||||||
|
if id in [None, '']:
|
||||||
|
flash(message='Please select a valid dataset.', category='error')
|
||||||
|
return redirect(url_for('analysis._analysis'))
|
||||||
|
try:
|
||||||
|
dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not dataset:
|
||||||
|
flash('Invalid dataset.', 'error')
|
||||||
|
return redirect(url_for('analysis._analysis'))
|
||||||
|
return render_template('/analysis/analysis.html', analysis=analyse(dataset), subject=dataset.get_name(), type='dataset', dataset=dataset, questions=parse_questions(dataset.get_data()))
|
@ -1,8 +1,10 @@
|
|||||||
from ..models import Dataset, Entry, User
|
from ..models import Dataset, Entry, User
|
||||||
from ..tools.data import validate_json
|
from ..tools.data import validate_json
|
||||||
|
from ..tools.logs import write
|
||||||
from ..tools.test import evaluate_answers, generate_questions
|
from ..tools.test import evaluate_answers, generate_questions
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, request, url_for
|
from flask import Blueprint, jsonify, request
|
||||||
|
from flask.helpers import abort, flash, url_for
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -16,7 +18,10 @@ api = Blueprint(
|
|||||||
@api.route('/questions/', methods=['POST'])
|
@api.route('/questions/', methods=['POST'])
|
||||||
def _fetch_questions():
|
def _fetch_questions():
|
||||||
id = request.get_json()['id']
|
id = request.get_json()['id']
|
||||||
entry = Entry.query.filter_by(id=id).first()
|
try: entry = Entry.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
|
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
|
||||||
test = entry.test
|
test = entry.test
|
||||||
user_code = entry.user_code
|
user_code = entry.user_code
|
||||||
@ -50,7 +55,10 @@ def _fetch_questions():
|
|||||||
def _submit_quiz():
|
def _submit_quiz():
|
||||||
id = request.get_json()['id']
|
id = request.get_json()['id']
|
||||||
answers = request.get_json()['answers']
|
answers = request.get_json()['answers']
|
||||||
entry = Entry.query.filter_by(id=id).first()
|
try: entry = Entry.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
|
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
|
||||||
test = entry.test
|
test = entry.test
|
||||||
dataset = test.dataset
|
dataset = test.dataset
|
||||||
@ -71,7 +79,10 @@ def _submit_quiz():
|
|||||||
def _editor(id:str=None):
|
def _editor(id:str=None):
|
||||||
request_data = request.get_json()
|
request_data = request.get_json()
|
||||||
id = request_data['id']
|
id = request_data['id']
|
||||||
dataset = Dataset.query.filter_by(id=id).first()
|
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
|
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
|
||||||
data_path = dataset.get_file()
|
data_path = dataset.get_file()
|
||||||
if request_data['action'] == 'fetch':
|
if request_data['action'] == 'fetch':
|
||||||
@ -80,10 +91,13 @@ def _editor(id:str=None):
|
|||||||
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
|
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
|
||||||
default = request_data['default']
|
default = request_data['default']
|
||||||
creator = request_data['creator']
|
creator = request_data['creator']
|
||||||
|
try: user = User.query.filter_by(id=creator).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
name = request_data['name']
|
name = request_data['name']
|
||||||
data = request_data['data']
|
data = request_data['data']
|
||||||
if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400
|
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.set_name(name)
|
||||||
dataset.creator = user
|
dataset.creator = user
|
||||||
success, message = dataset.update(data=data, default=default)
|
success, message = dataset.update(data=data, default=default)
|
||||||
|
@ -4,27 +4,41 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv('../.env')
|
load_dotenv('../.env')
|
||||||
|
|
||||||
class Config(object):
|
class Config(object):
|
||||||
|
"""Basic App Configuration"""
|
||||||
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')
|
||||||
SERVER_NAME = os.getenv('SERVER_NAME')
|
SERVER_NAME = os.getenv('SERVER_NAME')
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/database.db"))}'
|
WTF_CSRF_TIME_LIMIT = None
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
||||||
|
|
||||||
|
"""Email Engine Configuration"""
|
||||||
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)
|
||||||
|
|
||||||
|
"""Database Driver Configuration"""
|
||||||
|
DATABASE_TYPE = os.getenv('DATABASE_TYPE') or 'SQLite'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
if DATABASE_TYPE.lower() == 'mysql' and os.getenv('MYSQL_DATABASE') and os.getenv('MYSQL_USER') and os.getenv('MYSQL_PASSWORD'):
|
||||||
|
DATABASE_HOST = os.getenv('DATABASE_HOST') or 'localhost'
|
||||||
|
DATABASE_PORT = int(os.getenv('DATABASE_PORT') or 3306)
|
||||||
|
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE')
|
||||||
|
MYSQL_USER = os.getenv('MYSQL_USER')
|
||||||
|
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
|
||||||
|
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{MYSQL_DATABASE}'
|
||||||
|
else: SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/db.sqlite"))}'
|
||||||
|
|
||||||
class Production(Config):
|
class Production(Config):
|
||||||
pass
|
pass
|
||||||
|
@ -71,7 +71,7 @@
|
|||||||
margin: 30pt auto;
|
margin: 30pt auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-panel {
|
.info-panel, .viewer-panel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,3 +85,19 @@
|
|||||||
margin: 30px auto;
|
margin: 30px auto;
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
border: 2px solid black;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-body, .question-block {
|
||||||
|
padding: 0px 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding: 0px 2em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
@ -5,24 +5,69 @@ const id = $root.data('id')
|
|||||||
|
|
||||||
const $control_panel = $('.control-panel')
|
const $control_panel = $('.control-panel')
|
||||||
const $info_panel = $('.info-panel')
|
const $info_panel = $('.info-panel')
|
||||||
|
const $viewer_panel = $('.viewer-panel')
|
||||||
const $editor_panel = $('.editor-panel')
|
const $editor_panel = $('.editor-panel')
|
||||||
|
|
||||||
|
var toggle_info = false
|
||||||
|
var toggle_viewer = false
|
||||||
|
|
||||||
var element_index = 0
|
var element_index = 0
|
||||||
|
|
||||||
// Initialise Sortable and trigger renumbering on end of drag
|
// Initialise Sortable and trigger renumbering on end of drag
|
||||||
Sortable.create($root.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
Sortable.create($root.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||||
|
|
||||||
// Info Button Listener
|
// Info and Viewer Button Listener
|
||||||
$control_panel.find('button').click(function(event){
|
$control_panel.find('button').click(function(event){
|
||||||
|
var action = $(this).data('action');
|
||||||
|
|
||||||
|
if (action == 'info') {
|
||||||
if ($info_panel.is(":hidden")) {
|
if ($info_panel.is(":hidden")) {
|
||||||
|
if ($viewer_panel.is(":visible")) {
|
||||||
|
toggle_viewer = true
|
||||||
|
$viewer_panel.hide()
|
||||||
|
}
|
||||||
$editor_panel.hide()
|
$editor_panel.hide()
|
||||||
$info_panel.fadeIn()
|
$info_panel.fadeIn()
|
||||||
|
$(window).scrollTop(0)
|
||||||
|
toggle_info = false
|
||||||
$(this).addClass('active')
|
$(this).addClass('active')
|
||||||
} else {
|
} else {
|
||||||
$info_panel.hide()
|
$info_panel.hide()
|
||||||
|
if (toggle_viewer) {
|
||||||
|
render_viewer()
|
||||||
|
$(window).scrollTop(0)
|
||||||
|
toggle_viewer = false
|
||||||
|
} else {
|
||||||
$editor_panel.fadeIn()
|
$editor_panel.fadeIn()
|
||||||
|
$(window).scrollTop(0)
|
||||||
|
}
|
||||||
$(this).removeClass('active')
|
$(this).removeClass('active')
|
||||||
}
|
}
|
||||||
|
} else if (action == 'view') {
|
||||||
|
if ($viewer_panel.is(":hidden")) {
|
||||||
|
if ($info_panel.is(':visible')) {
|
||||||
|
toggle_info = true
|
||||||
|
$info_panel.hide()
|
||||||
|
}
|
||||||
|
$editor_panel.hide()
|
||||||
|
render_viewer()
|
||||||
|
$(window).scrollTop(0)
|
||||||
|
toggle_viewer = false
|
||||||
|
$(this).addClass('active')
|
||||||
|
} else {
|
||||||
|
$viewer_panel.hide()
|
||||||
|
if (toggle_info) {
|
||||||
|
$info_panel.fadeIn()
|
||||||
|
$(window).scrollTop(0)
|
||||||
|
toggle_info = false
|
||||||
|
} else {
|
||||||
|
$editor_panel.fadeIn()
|
||||||
|
$(window).scrollTop(0)
|
||||||
|
}
|
||||||
|
$(this).removeClass('active')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -497,3 +542,101 @@ $(window).on('load', function() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Viewer Render Function
|
||||||
|
function render_viewer() {
|
||||||
|
$viewer_panel.fadeIn()
|
||||||
|
$viewer_panel.empty()
|
||||||
|
var heading = document.createElement('h3')
|
||||||
|
heading.innerText = 'View Questions'
|
||||||
|
$viewer_panel.append(heading)
|
||||||
|
var data = parse_input()
|
||||||
|
var block
|
||||||
|
var obj
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
block = data[i]
|
||||||
|
obj = document.createElement('div')
|
||||||
|
obj.classList = 'block'
|
||||||
|
if (block['type'] == 'question') {
|
||||||
|
text = document.createElement('p')
|
||||||
|
text.innerHTML = `<strong>Question ${block['q_no'] + 1}.</strong> ${block['text']}`
|
||||||
|
obj.append(text)
|
||||||
|
question_body = document.createElement('div')
|
||||||
|
question_body.className ='question-body'
|
||||||
|
type = document.createElement('p')
|
||||||
|
type.innerHTML = `<strong>Question Type:</strong> ${block['q_type']}`
|
||||||
|
question_body.append(type)
|
||||||
|
options = document.createElement('p')
|
||||||
|
options.innerHTML = '<strong>Options:</strong>'
|
||||||
|
option_list = document.createElement('ul')
|
||||||
|
for (let _i = 0; _i < block['options'].length; _i++) {
|
||||||
|
option = document.createElement('li')
|
||||||
|
option.innerHTML = block['options'][_i]
|
||||||
|
if (block['correct'] == _i) {
|
||||||
|
option.innerHTML += ' <span class="badge rounded-pill bg-success">Correct</span>'
|
||||||
|
}
|
||||||
|
option_list.append(option)
|
||||||
|
}
|
||||||
|
options.append(option_list)
|
||||||
|
question_body.append(options)
|
||||||
|
tags = document.createElement('p')
|
||||||
|
tags.innerHTML = `<strong>Tags:</strong>`
|
||||||
|
tag_list = document.createElement('ul')
|
||||||
|
for (let _i = 0; _i < block['tags'].length; _i++) {
|
||||||
|
tag = document.createElement('li')
|
||||||
|
tag.innerHTML = block['tags'][_i]
|
||||||
|
tag_list.append(tag)
|
||||||
|
}
|
||||||
|
tags.append(tag_list)
|
||||||
|
question_body.append(tags)
|
||||||
|
obj.append(question_body)
|
||||||
|
} else if (block['type'] == 'block') {
|
||||||
|
meta = document.createElement('p')
|
||||||
|
meta.innerHTML = `<strong>Block ${i+1}.</strong> ${block['questions'].length} questions.`
|
||||||
|
obj.append(meta)
|
||||||
|
header = document.createElement('blockquote')
|
||||||
|
header.innerText = block['question_header']
|
||||||
|
obj.append(header)
|
||||||
|
var block_question = document.createElement('div')
|
||||||
|
var question
|
||||||
|
block_question.className = 'question-block'
|
||||||
|
for (let _i = 0; _i < block['questions'].length; _i++) {
|
||||||
|
question = block['questions'][_i]
|
||||||
|
text = document.createElement('p')
|
||||||
|
text.innerHTML = `<strong>Question ${question['q_no'] + 1}.</strong> ${question['text']}`
|
||||||
|
block_question.append(text)
|
||||||
|
question_body = document.createElement('div')
|
||||||
|
question_body.className ='question-body'
|
||||||
|
type = document.createElement('p')
|
||||||
|
type.innerHTML = `<strong>Question Type:</strong> ${question['q_type']}`
|
||||||
|
question_body.append(type)
|
||||||
|
options = document.createElement('p')
|
||||||
|
options.innerHTML = '<strong>Options:</strong>'
|
||||||
|
option_list = document.createElement('ul')
|
||||||
|
for (let __i = 0; __i < question['options'].length; __i++) {
|
||||||
|
option = document.createElement('li')
|
||||||
|
option.innerHTML = question['options'][__i]
|
||||||
|
if (question['correct'] == __i) {
|
||||||
|
option.innerHTML += ' <span class="badge rounded-pill bg-success">Correct</span>'
|
||||||
|
}
|
||||||
|
option_list.append(option)
|
||||||
|
}
|
||||||
|
options.append(option_list)
|
||||||
|
question_body.append(options)
|
||||||
|
tags = document.createElement('p')
|
||||||
|
tags.innerHTML = `<strong>Tags:</strong>`
|
||||||
|
tag_list = document.createElement('ul')
|
||||||
|
for (let __i = 0; __i < question['tags'].length; __i++) {
|
||||||
|
tag = document.createElement('li')
|
||||||
|
tag.innerHTML = question['tags'][__i]
|
||||||
|
tag_list.append(tag)
|
||||||
|
}
|
||||||
|
tags.append(tag_list)
|
||||||
|
question_body.append(tags)
|
||||||
|
block_question.append(question_body)
|
||||||
|
obj.append(block_question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$viewer_panel.append(obj)
|
||||||
|
}
|
||||||
|
}
|
@ -57,7 +57,7 @@ function error_response(response) {
|
|||||||
`);
|
`);
|
||||||
} else if (response.responseJSON.error instanceof Array) {
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
var output = ''
|
var output = ''
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
output += `
|
output += `
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -1,6 +1,6 @@
|
|||||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||||
<button
|
<button
|
||||||
class="navbar-toggler"
|
class="navbar-toggler"
|
||||||
type="button"
|
type="button"
|
||||||
@ -20,8 +20,28 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<li class="nav-item" id="nav-results">
|
<li class="nav-item dropdown" id="nav-results">
|
||||||
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-results"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._view_entries') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-results"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="nav-tests">
|
<li class="nav-item dropdown" id="nav-tests">
|
||||||
<a
|
<a
|
||||||
@ -36,7 +56,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul
|
<ul
|
||||||
class="dropdown-menu"
|
class="dropdown-menu"
|
||||||
aria-labelledby="dropdown-settings"
|
aria-labelledby="dropdown-tests"
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||||
@ -58,7 +78,7 @@
|
|||||||
<li class="nav-item dropdown" id="nav-settings">
|
<li class="nav-item dropdown" id="nav-settings">
|
||||||
<a
|
<a
|
||||||
class="nav-link dropdown-toggle"
|
class="nav-link dropdown-toggle"
|
||||||
id="dropdown-account"
|
id="dropdown-settings"
|
||||||
role="button"
|
role="button"
|
||||||
href="{{ url_for('admin._settings') }}"
|
href="{{ url_for('admin._settings') }}"
|
||||||
data-bs-toggle="dropdown"
|
data-bs-toggle="dropdown"
|
||||||
@ -77,10 +97,13 @@
|
|||||||
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</a>
|
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
@ -11,11 +11,12 @@
|
|||||||
<h1>Editor</h1>
|
<h1>Editor</h1>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Use this console to edit the questions in this dataset. For more information on using the editor console, click on the the blue information button.
|
Use this console to edit the questions in this dataset. For more information on using the editor console, click on the the blue Information button. To preview the questions in the current dataset, click on the green View Questions button.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="container control-panel">
|
<div class="container control-panel">
|
||||||
<button class="btn btn-primary" aria-title="Infrmation" title="Information"><i class="bi bi-info-circle-fill"></i></button>
|
<button class="btn btn-primary" aria-title="Information" title="Information" data-action="info"><i class="bi bi-info-circle-fill"></i></button>
|
||||||
|
<button class="btn btn-success" aria-title="View Questions" title="View Questions" data-action="view"><i class="bi bi-book-fill"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="container info-panel">
|
<div class="container info-panel">
|
||||||
<h3>
|
<h3>
|
||||||
@ -77,9 +78,11 @@
|
|||||||
In order to show how many questions are remaining inside a block, e.g. to say ‘the next n questions are about a specific scenario’, use the placeholder <code><block_remaining_questions></code>.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="container viewer-panel">
|
||||||
|
</div>
|
||||||
<div class="container editor-panel">
|
<div class="container editor-panel">
|
||||||
<h3>
|
<h3>
|
||||||
Edit Dataset
|
Edit Questions
|
||||||
</h3>
|
</h3>
|
||||||
<div class="container dataset-metadata">
|
<div class="container dataset-metadata">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
@ -98,7 +101,7 @@
|
|||||||
<span class="input-group-text">Last Updated</span>
|
<span class="input-group-text">Last Updated</span>
|
||||||
<span class="form-control">
|
<span class="form-control">
|
||||||
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
|
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
|
||||||
</select>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<span class="input-group-text">
|
<span class="input-group-text">
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-editor" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for(request.endpoint, **request.view_args) }}">
|
<form name="form-editor" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for(request.endpoint, **request.view_args) }}">
|
||||||
{% include "admin/components/server-alerts.html" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form">Dataset Editor</h2>
|
<h2 class="form">Edit Questions</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="form-select-input">
|
<div class="form-select-input">
|
||||||
{{ form.dataset(placeholder="Select Question Dataset") }}
|
{{ form.dataset(placeholder="Select Question Dataset") }}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
from ..forms.admin import EditDataset
|
from ..forms.admin import EditDataset
|
||||||
from ..models import Dataset, User
|
from ..models import Dataset, User
|
||||||
|
from ..tools.data import check_dataset_exists
|
||||||
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
||||||
|
from ..tools.logs import write
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request
|
from flask import Blueprint, jsonify, render_template
|
||||||
from flask.helpers import url_for
|
from flask.helpers import abort, flash, redirect, request, url_for
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
|
||||||
editor = Blueprint(
|
editor = Blueprint(
|
||||||
@ -27,11 +29,16 @@ def _editor():
|
|||||||
return render_template('/editor/index.html', form=form)
|
return render_template('/editor/index.html', form=form)
|
||||||
|
|
||||||
@editor.route('/<string:id>/')
|
@editor.route('/<string:id>/')
|
||||||
|
@check_dataset_exists
|
||||||
@login_required
|
@login_required
|
||||||
def _editor_console(id:str=None):
|
def _editor_console(id:str=None):
|
||||||
|
try:
|
||||||
dataset = Dataset.query.filter_by(id=id).first()
|
dataset = Dataset.query.filter_by(id=id).first()
|
||||||
datasets = Dataset.query.count()
|
datasets = Dataset.query.count()
|
||||||
users = User.query.all()
|
users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not dataset:
|
if not dataset:
|
||||||
flash('Invalid dataset ID.', 'error')
|
flash('Invalid dataset ID.', 'error')
|
||||||
return redirect(url_for('admin._questions'))
|
return redirect(url_for('admin._questions'))
|
||||||
|
@ -6,53 +6,51 @@ 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=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
remember = BooleanField('Remember Log In', render_kw={'checked': True})
|
remember = BooleanField('Remember Log In', render_kw={'checked': True})
|
||||||
|
|
||||||
class Register(FlaskForm):
|
class Register(FlaskForm):
|
||||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
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=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
||||||
|
|
||||||
class ResetPassword(FlaskForm):
|
class ResetPassword(FlaskForm):
|
||||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
|
|
||||||
class UpdatePassword(FlaskForm):
|
class UpdatePassword(FlaskForm):
|
||||||
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=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
||||||
|
|
||||||
class CreateUser(FlaskForm):
|
class CreateUser(FlaskForm):
|
||||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
notify = BooleanField('Notify accout creation by email', render_kw={'checked': True})
|
notify = BooleanField('Notify accout creation by email', render_kw={'checked': True})
|
||||||
|
|
||||||
class DeleteUser(FlaskForm):
|
class DeleteUser(FlaskForm):
|
||||||
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
|
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
|
||||||
|
|
||||||
class UpdateUser(FlaskForm):
|
class UpdateUser(FlaskForm):
|
||||||
confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
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.')])
|
||||||
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
|
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
|
||||||
|
|
||||||
class UpdateAccount(FlaskForm):
|
class UpdateAccount(FlaskForm):
|
||||||
confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||||
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')
|
||||||
|
|
||||||
|
@ -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)
|
|
@ -2,22 +2,22 @@ from ..extensions import db
|
|||||||
from ..tools.encryption import decrypt, encrypt
|
from ..tools.encryption import decrypt, encrypt
|
||||||
from ..tools.logs import write
|
from ..tools.logs import write
|
||||||
|
|
||||||
from flask import flash
|
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
|
from flask.helpers import flash
|
||||||
from flask_login import current_user
|
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
|
from json import dump, loads
|
||||||
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
|
||||||
|
|
||||||
class Dataset(db.Model):
|
class Dataset(db.Model):
|
||||||
|
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||||
id = db.Column(db.String(36), primary_key=True)
|
|
||||||
name = db.Column(db.String(128), nullable=False)
|
name = db.Column(db.String(128), nullable=False)
|
||||||
tests = db.relationship('Test', backref='dataset')
|
tests = db.relationship('Test', backref='dataset')
|
||||||
|
entries = db.relationship('Entry', 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)
|
||||||
@ -42,10 +42,17 @@ class Dataset(db.Model):
|
|||||||
def get_name(self): return decrypt(self.name)
|
def get_name(self): return decrypt(self.name)
|
||||||
|
|
||||||
def make_default(self):
|
def make_default(self):
|
||||||
for dataset in Dataset.query.all():
|
try:
|
||||||
dataset.default = False
|
for dataset in Dataset.query.all(): dataset.default = False
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||||
|
return False, f'Database error {exception}.'
|
||||||
self.default = True
|
self.default = True
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||||
|
return False, f'Database error {exception}.'
|
||||||
write('system.log', f'Dataset {self.id} set as default by {current_user.get_username()}.')
|
write('system.log', f'Dataset {self.id} set as default by {current_user.get_username()}.')
|
||||||
flash(message='Dataset set as default.', category='success')
|
flash(message='Dataset set as default.', category='success')
|
||||||
return True, f'Dataset set as default.'
|
return True, f'Dataset set as default.'
|
||||||
@ -55,17 +62,26 @@ 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
|
||||||
|
try:
|
||||||
if Dataset.query.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
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||||
|
return False, f'Database error {exception}.'
|
||||||
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
|
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
|
||||||
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)
|
||||||
remove(file_path)
|
try:
|
||||||
db.session.delete(self)
|
db.session.delete(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when trying to delete dataset {self.id}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
|
remove(file_path)
|
||||||
return True, 'Dataset deleted.'
|
return True, 'Dataset deleted.'
|
||||||
|
|
||||||
def create(self, data:list, default:bool=False):
|
def create(self, data:list, default:bool=False):
|
||||||
@ -78,8 +94,13 @@ class Dataset(db.Model):
|
|||||||
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.get_name()} added by {current_user.get_username()}.')
|
write('system.log', f'New dataset {self.get_name()} added by {current_user.get_username()}.')
|
||||||
|
try:
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when trying to crreate dataset {self.id}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
return True, 'Dataset created.'
|
return True, 'Dataset created.'
|
||||||
|
|
||||||
def check_file(self):
|
def check_file(self):
|
||||||
@ -95,6 +116,12 @@ class Dataset(db.Model):
|
|||||||
file_path = path.join(data, 'questions', filename)
|
file_path = path.join(data, 'questions', filename)
|
||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
dataset_path = self.get_file()
|
||||||
|
with open(dataset_path, 'r') as _dataset:
|
||||||
|
data = loads(_dataset.read())
|
||||||
|
return data
|
||||||
|
|
||||||
def update(self, data:list=None, default:bool=False):
|
def update(self, data:list=None, default:bool=False):
|
||||||
self.date = datetime.now()
|
self.date = datetime.now()
|
||||||
if default: self.make_default()
|
if default: self.make_default()
|
||||||
@ -102,7 +129,12 @@ class Dataset(db.Model):
|
|||||||
with open(file_path, 'w') as file:
|
with open(file_path, 'w') as file:
|
||||||
dump(data, file, indent=2)
|
dump(data, file, indent=2)
|
||||||
write('system.log', f'Dataset {self.id} edited by {current_user.get_username()}.')
|
write('system.log', f'Dataset {self.id} edited by {current_user.get_username()}.')
|
||||||
flash(f'Dataset {self.name} successfully edited.', 'success')
|
flash(f'Dataset {self.get_name()} successfully edited.', 'success')
|
||||||
|
try:
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when trying to update dataset {self.id}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
return True, 'Dataset successfully edited.'
|
return True, 'Dataset successfully edited.'
|
@ -1,30 +1,31 @@
|
|||||||
from ..extensions import db, mail
|
from ..extensions import db, mail
|
||||||
from ..tools.forms import JsonEncodedDict
|
|
||||||
from ..tools.encryption import decrypt, encrypt
|
from ..tools.encryption import decrypt, encrypt
|
||||||
from ..tools.logs import write
|
from ..tools.logs import write
|
||||||
from .test import Test
|
from .test import Test
|
||||||
|
from .dataset import Dataset
|
||||||
|
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
|
from sqlalchemy_json import MutableJson
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
class Entry(db.Model):
|
class Entry(db.Model):
|
||||||
|
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||||
id = db.Column(db.String(36), primary_key=True)
|
|
||||||
first_name = db.Column(db.String(128), nullable=False)
|
first_name = db.Column(db.String(128), nullable=False)
|
||||||
surname = db.Column(db.String(128), nullable=False)
|
surname = db.Column(db.String(128), nullable=False)
|
||||||
email = db.Column(db.String(128), nullable=False)
|
email = db.Column(db.String(128), nullable=False)
|
||||||
club = db.Column(db.String(128), nullable=True)
|
club = db.Column(db.String(128), nullable=True)
|
||||||
test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
|
test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
|
||||||
|
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
|
||||||
user_code = db.Column(db.String(6), nullable=True)
|
user_code = db.Column(db.String(6), nullable=True)
|
||||||
start_time = db.Column(db.DateTime, nullable=True)
|
start_time = db.Column(db.DateTime, index=True, nullable=True)
|
||||||
end_time = db.Column(db.DateTime, nullable=True)
|
end_time = db.Column(db.DateTime, index=True, nullable=True)
|
||||||
status = db.Column(db.String(16), nullable=True)
|
status = db.Column(db.String(16), nullable=True)
|
||||||
valid = db.Column(db.Boolean, default=True, nullable=True)
|
valid = db.Column(db.Boolean, default=True, nullable=True)
|
||||||
answers = db.Column(JsonEncodedDict, nullable=True)
|
answers = db.Column(MutableJson, nullable=True)
|
||||||
result = db.Column(JsonEncodedDict, nullable=True)
|
result = db.Column(MutableJson, nullable=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.'
|
return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.'
|
||||||
@ -69,23 +70,31 @@ class Entry(db.Model):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
self.generate_id()
|
self.generate_id()
|
||||||
|
try:
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
write('tests.log', f'New test ready for {self.get_first_name()} {self.get_surname()}.')
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when preparing new entry for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
|
write('tests.log', f'New test ready for {self.get_surname()}, {self.get_first_name()} with id {self.id}.')
|
||||||
return True, f'Test ready.'
|
return True, f'Test ready.'
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self.start_time = datetime.now()
|
self.start_time = datetime.now()
|
||||||
self.status = 'started'
|
self.status = 'started'
|
||||||
write('tests.log', f'Test started by {self.get_first_name()} {self.get_surname()}.')
|
try: db.session.commit()
|
||||||
db.session.commit()
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when starting test for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
|
write('tests.log', f'Test started by {self.get_surname()}, {self.get_first_name()} with id {self.id}.')
|
||||||
return True, f'New test started with id {self.id}.'
|
return True, f'New test started with id {self.id}.'
|
||||||
|
|
||||||
def complete(self, answers:dict=None, result:dict=None):
|
def complete(self, answers:dict=None, result:dict=None):
|
||||||
self.end_time = datetime.now()
|
self.end_time = datetime.now()
|
||||||
self.answers = answers
|
self.answers = answers
|
||||||
self.result = result
|
self.result = result
|
||||||
write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.')
|
|
||||||
delta = timedelta(minutes=int(0 if self.test.time_limit is None else self.test.time_limit)+1)
|
delta = timedelta(minutes=int(0 if self.test.time_limit is None else self.test.time_limit)+1)
|
||||||
if not self.test.time_limit or self.end_time <= self.start_time + delta:
|
if not self.test.time_limit or self.end_time <= self.start_time + delta:
|
||||||
self.status = 'completed'
|
self.status = 'completed'
|
||||||
@ -93,7 +102,12 @@ class Entry(db.Model):
|
|||||||
else:
|
else:
|
||||||
self.status = 'late'
|
self.status = 'late'
|
||||||
self.valid = False
|
self.valid = False
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when submitting entry for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
|
write('tests.log', f'Test completed by {self.get_surname()}, {self.get_first_name()} with id {self.id}.')
|
||||||
return True, f'Test entry completed for id {self.id}.'
|
return True, f'Test entry completed for id {self.id}.'
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
@ -101,15 +115,24 @@ class Entry(db.Model):
|
|||||||
if self.status == 'started': return False, 'The entry is still pending.'
|
if self.status == 'started': return False, 'The entry is still pending.'
|
||||||
self.valid = True
|
self.valid = True
|
||||||
self.status = 'completed'
|
self.status = 'completed'
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when validating entry {self.id}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'The entry {self.id} has been validated by {current_user.get_username()}.')
|
write('system.log', f'The entry {self.id} has been validated by {current_user.get_username()}.')
|
||||||
return True, f'The entry {self.id} has been validated.'
|
return True, f'The entry {self.id} has been validated.'
|
||||||
|
|
||||||
def delete(self):
|
def delete(self):
|
||||||
id = self.id
|
id = self.id
|
||||||
name = f'{self.get_first_name()} {self.get_surname()}'
|
name = f'{self.get_first_name()} {self.get_surname()}'
|
||||||
|
try:
|
||||||
db.session.delete(self)
|
db.session.delete(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when deleting entry {id}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'The entry {id} by {name} has been deleted by {current_user.get_username()}.')
|
write('system.log', f'The entry {id} by {name} has been deleted by {current_user.get_username()}.')
|
||||||
return True, 'Entry deleted.'
|
return True, 'Entry deleted.'
|
||||||
|
|
||||||
@ -174,4 +197,5 @@ class Entry(db.Model):
|
|||||||
<p>Best wishes, <br/> SKA Refereeing</p>
|
<p>Best wishes, <br/> SKA Refereeing</p>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
mail.send(email)
|
try: mail.send(email)
|
||||||
|
except Exception as exception: write('system.log', f'SMTP Error when trying to notify results to {self.get_surname()}, {self.get_first_name()} with error: {exception}')
|
@ -1,23 +1,22 @@
|
|||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..tools.forms import JsonEncodedDict
|
|
||||||
from ..tools.logs import write
|
from ..tools.logs import write
|
||||||
|
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
from sqlalchemy_json import MutableJson
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
import secrets
|
import secrets
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
class Test(db.Model):
|
class Test(db.Model):
|
||||||
|
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||||
id = db.Column(db.String(36), primary_key=True)
|
code = db.Column(db.String(36), index=True, nullable=False)
|
||||||
code = db.Column(db.String(36), nullable=False)
|
start_date = db.Column(db.DateTime, index=True, nullable=True)
|
||||||
start_date = db.Column(db.DateTime, nullable=True)
|
|
||||||
end_date = db.Column(db.DateTime, nullable=True)
|
end_date = db.Column(db.DateTime, nullable=True)
|
||||||
time_limit = db.Column(db.Integer, nullable=True)
|
time_limit = db.Column(db.Integer, nullable=True)
|
||||||
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||||
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
|
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
|
||||||
adjustments = db.Column(JsonEncodedDict, nullable=True)
|
adjustments = db.Column(MutableJson, nullable=True)
|
||||||
entries = db.relationship('Entry', backref='test')
|
entries = db.relationship('Entry', backref='test')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@ -52,16 +51,24 @@ class Test(db.Model):
|
|||||||
errors.append('The expiry date cannot be before the start date.')
|
errors.append('The expiry date cannot be before the start date.')
|
||||||
if errors:
|
if errors:
|
||||||
return False, errors
|
return False, errors
|
||||||
|
try:
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when creating test {self.get_code()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'Test with code {self.get_code()} created by {current_user.get_username()}.')
|
write('system.log', f'Test with code {self.get_code()} created by {current_user.get_username()}.')
|
||||||
return True, f'Test with code {self.get_code()} has been created.'
|
return True, f'Test with code {self.get_code()} has been created.'
|
||||||
|
|
||||||
def delete(self):
|
def delete(self):
|
||||||
code = self.code
|
|
||||||
if self.entries: return False, f'Cannot delete a test with submitted entries.'
|
if self.entries: return False, f'Cannot delete a test with submitted entries.'
|
||||||
db.session.delete(self)
|
db.session.delete(self)
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when deleting test {self.get_code()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'Test with code {self.get_code()} has been deleted by {current_user.get_username()}.')
|
write('system.log', f'Test with code {self.get_code()} has been deleted by {current_user.get_username()}.')
|
||||||
return True, f'Test with code {self.get_code()} has been deleted.'
|
return True, f'Test with code {self.get_code()} has been deleted.'
|
||||||
|
|
||||||
@ -69,7 +76,11 @@ class Test(db.Model):
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
if self.start_date.date() > now.date():
|
if self.start_date.date() > now.date():
|
||||||
self.start_date = now
|
self.start_date = now
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when launching test {self.get_code()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'Test with code {self.get_code()} has been started by {current_user.get_username()}.')
|
write('system.log', f'Test with code {self.get_code()} has been started by {current_user.get_username()}.')
|
||||||
return True, f'Test with code {self.get_code()} has been started.'
|
return True, f'Test with code {self.get_code()} has been started.'
|
||||||
return False, f'Test with code {self.get_code()} has already started.'
|
return False, f'Test with code {self.get_code()} has already started.'
|
||||||
@ -78,7 +89,11 @@ class Test(db.Model):
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
if self.end_date >= now:
|
if self.end_date >= now:
|
||||||
self.end_date = now
|
self.end_date = now
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when closing test {self.get_code()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'Test with code {self.get_code()} ended by {current_user.get_username()}.')
|
write('system.log', f'Test with code {self.get_code()} ended by {current_user.get_username()}.')
|
||||||
return True, f'Test with code {self.get_code()} has been ended.'
|
return True, f'Test with code {self.get_code()} has been ended.'
|
||||||
return False, f'Test with code {self.get_code()} has already ended.'
|
return False, f'Test with code {self.get_code()} has already ended.'
|
||||||
@ -88,7 +103,11 @@ class Test(db.Model):
|
|||||||
code = secrets.token_hex(3).lower()
|
code = secrets.token_hex(3).lower()
|
||||||
adjustments[code] = time
|
adjustments[code] = time
|
||||||
self.adjustments = adjustments
|
self.adjustments = adjustments
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when adding adjustment to test {self.get_code()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'Time adjustment for {time} minutes with code {code} added to test {self.get_code()} by {current_user.get_username()}.')
|
write('system.log', f'Time adjustment for {time} minutes with code {code} added to test {self.get_code()} by {current_user.get_username()}.')
|
||||||
return True, f'Time adjustment for {time} minutes added to test {self.get_code()}. This can be accessed using the user code {code.upper()}.'
|
return True, f'Time adjustment for {time} minutes added to test {self.get_code()}. This can be accessed using the user code {code.upper()}.'
|
||||||
|
|
||||||
@ -96,7 +115,11 @@ class Test(db.Model):
|
|||||||
if not self.adjustments: return False, f'There are no adjustments configured for test {self.get_code()}.'
|
if not self.adjustments: return False, f'There are no adjustments configured for test {self.get_code()}.'
|
||||||
self.adjustments.pop(code)
|
self.adjustments.pop(code)
|
||||||
if not self.adjustments: self.adjustments = None
|
if not self.adjustments: self.adjustments = None
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when deleting adjustment from test {self.get_code()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'Time adjustment for with code {code} has been removed from test {self.get_code()} by {current_user.get_username()}.')
|
write('system.log', f'Time adjustment for with code {code} has been removed from test {self.get_code()} by {current_user.get_username()}.')
|
||||||
return True, f'Time adjustment for with code {code} has been removed from test {self.get_code()}.'
|
return True, f'Time adjustment for with code {code} has been removed from test {self.get_code()}.'
|
||||||
|
|
||||||
@ -105,6 +128,10 @@ class Test(db.Model):
|
|||||||
if start_date: self.start_date = start_date
|
if start_date: self.start_date = start_date
|
||||||
if end_date: self.end_date = end_date
|
if end_date: self.end_date = end_date
|
||||||
if time_limit is not None: self.time_limit = time_limit
|
if time_limit is not None: self.time_limit = time_limit
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when updating test {self.get_code()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('system.log', f'Test with code {self.get_code()} has been updated by user {current_user.get_username()}.')
|
write('system.log', f'Test with code {self.get_code()} has been updated by user {current_user.get_username()}.')
|
||||||
return True, f'Test with code {self.get_code()} has been updated by.'
|
return True, f'Test with code {self.get_code()} has been updated by.'
|
@ -2,8 +2,8 @@ from ..extensions import db, mail
|
|||||||
from ..tools.encryption import decrypt, encrypt
|
from ..tools.encryption import decrypt, encrypt
|
||||||
from ..tools.logs import write
|
from ..tools.logs import write
|
||||||
|
|
||||||
from flask import flash, jsonify, session
|
from flask import jsonify, session
|
||||||
from flask.helpers import url_for
|
from flask.helpers import flash, url_for
|
||||||
from flask_login import current_user, login_user, logout_user, UserMixin
|
from flask_login import current_user, login_user, logout_user, UserMixin
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
@ -11,11 +11,11 @@ from werkzeug.security import check_password_hash, generate_password_hash
|
|||||||
import secrets
|
import secrets
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
id = db.Column(db.String(36), primary_key=True)
|
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||||
username = db.Column(db.String(128), nullable=False)
|
username = db.Column(db.String(128), nullable=False)
|
||||||
password = db.Column(db.String(128), nullable=False)
|
password = db.Column(db.String(128), nullable=False)
|
||||||
email = db.Column(db.String(128), nullable=False)
|
email = db.Column(db.String(128), nullable=False)
|
||||||
reset_token = db.Column(db.String(20), nullable=True)
|
reset_token = db.Column(db.String(20), index=True, nullable=True)
|
||||||
verification_token = db.Column(db.String(20), nullable=True)
|
verification_token = db.Column(db.String(20), nullable=True)
|
||||||
tests = db.relationship('Test', backref='creator')
|
tests = db.relationship('Test', backref='creator')
|
||||||
datasets = db.relationship('Dataset', backref='creator')
|
datasets = db.relationship('Dataset', backref='creator')
|
||||||
@ -55,13 +55,21 @@ class User(UserMixin, db.Model):
|
|||||||
|
|
||||||
def register(self, notify:bool=False, password:str=None):
|
def register(self, notify:bool=False, password:str=None):
|
||||||
self.generate_id()
|
self.generate_id()
|
||||||
users = User.query.all()
|
try: users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||||
|
return False, f'Database error {exception}.'
|
||||||
for user in users:
|
for user in users:
|
||||||
if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.'
|
if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.'
|
||||||
if user.get_email() == self.get_email(): return False, f'Email address {self.get_email()} already in use.'
|
if user.get_email() == self.get_email(): return False, f'Email address {self.get_email()} already in use.'
|
||||||
self.set_password(password=password)
|
self.set_password(password=password)
|
||||||
|
try:
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when registering user {self.get_username()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
write('users.log', f'User \'{self.get_username()}\' was created with id \'{self.id}\'.')
|
write('users.log', f'User \'{self.get_username()}\' was created with id \'{self.id}\'.')
|
||||||
if notify:
|
if notify:
|
||||||
email = Message(
|
email = Message(
|
||||||
@ -90,7 +98,8 @@ class User(UserMixin, db.Model):
|
|||||||
<p>SKA Refereeing</p>
|
<p>SKA Refereeing</p>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
mail.send(email)
|
try: mail.send(email)
|
||||||
|
except Exception as exception: write('system.log', f'SMTP Error while trying to notify new user account creation to {self.get_username()} with error: {exception}')
|
||||||
return True, f'User {self.get_username()} was created successfully.'
|
return True, f'User {self.get_username()} was created successfully.'
|
||||||
|
|
||||||
def login(self, remember:bool=False):
|
def login(self, remember:bool=False):
|
||||||
@ -109,7 +118,6 @@ class User(UserMixin, db.Model):
|
|||||||
self.set_password(new_password)
|
self.set_password(new_password)
|
||||||
self.reset_token = secrets.token_urlsafe(16)
|
self.reset_token = secrets.token_urlsafe(16)
|
||||||
self.verification_token = secrets.token_urlsafe(16)
|
self.verification_token = secrets.token_urlsafe(16)
|
||||||
db.session.commit()
|
|
||||||
email = Message(
|
email = Message(
|
||||||
subject='RefTest | Password Reset',
|
subject='RefTest | Password Reset',
|
||||||
recipients=[self.get_email()],
|
recipients=[self.get_email()],
|
||||||
@ -142,22 +150,36 @@ class User(UserMixin, db.Model):
|
|||||||
<p>SKA Refereeing</p>
|
<p>SKA Refereeing</p>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
mail.send(email)
|
try: mail.send(email)
|
||||||
print('Password', new_password)
|
except Exception as exception:
|
||||||
print('Reset Token', self.reset_token)
|
write('system.log', f'SMTP Error while trying to reset password for {self.get_username()} with error: {exception}')
|
||||||
print('Verification Token', self.verification_token)
|
db.session.rollback()
|
||||||
print('Reset Link', f'{url_for("admin._reset", token=self.reset_token, verification=self.verification_token, _external=True)}')
|
return jsonify({'error': f'SMTP Error: {exception}'}), 500
|
||||||
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when resetting password for user {self.get_username()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
return jsonify({'success': 'Your password reset link has been generated.'}), 200
|
return jsonify({'success': 'Your password reset link has been generated.'}), 200
|
||||||
|
|
||||||
def clear_reset_tokens(self):
|
def clear_reset_tokens(self):
|
||||||
self.reset_token = self.verification_token = None
|
self.reset_token = self.verification_token = None
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when resetting clearing reset tokens for user {self.get_username()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
|
|
||||||
def delete(self, notify:bool=False):
|
def delete(self, notify:bool=False):
|
||||||
username = self.get_username()
|
username = self.get_username()
|
||||||
email_address = self.get_email()
|
email_address = self.get_email()
|
||||||
|
try:
|
||||||
db.session.delete(self)
|
db.session.delete(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when deleting user {self.get_username()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
message = f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.'
|
message = f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.'
|
||||||
write('users.log', message)
|
write('users.log', message)
|
||||||
if notify:
|
if notify:
|
||||||
@ -182,7 +204,8 @@ class User(UserMixin, db.Model):
|
|||||||
<p>SKA Refereeing</p>
|
<p>SKA Refereeing</p>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
mail.send(email)
|
try: mail.send(email)
|
||||||
|
except Exception as exception: write('system.log', f'SMTP Error when trying to delete account {username} with error: {exception}')
|
||||||
return True, message
|
return True, message
|
||||||
|
|
||||||
def update(self, password:str=None, email:str=None, notify:bool=False):
|
def update(self, password:str=None, email:str=None, notify:bool=False):
|
||||||
@ -190,11 +213,20 @@ class User(UserMixin, db.Model):
|
|||||||
if password: self.set_password(password)
|
if password: self.set_password(password)
|
||||||
old_email = self.get_email()
|
old_email = self.get_email()
|
||||||
if email:
|
if email:
|
||||||
|
try:
|
||||||
for entry in User.query.all():
|
for entry in User.query.all():
|
||||||
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.'
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||||
|
return False, f'Database error {exception}.'
|
||||||
self.set_email(email)
|
self.set_email(email)
|
||||||
db.session.commit()
|
try: db.session.commit()
|
||||||
write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.')
|
except Exception as exception:
|
||||||
|
db.session.rollback()
|
||||||
|
write('system.log', f'Database error when updating user {self.get_username()}: {exception}')
|
||||||
|
return False, f'Database error: {exception}'
|
||||||
|
_current_user = 'command line' if not current_user else 'anonymous' if not current_user.is_authenticated else current_user.get_username()
|
||||||
|
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 +234,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 +245,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>
|
||||||
@ -222,5 +254,6 @@ class User(UserMixin, db.Model):
|
|||||||
<p>SKA Refereeing</p>
|
<p>SKA Refereeing</p>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
mail.send(message)
|
try: mail.send(message)
|
||||||
|
except Exception as exception: write('system.log', f'SMTP Error when trying to update account {self.get_username()} with error: {exception}')
|
||||||
return True, f'Account {self.get_username()} has been updated.'
|
return True, f'Account {self.get_username()} has been updated.'
|
||||||
|
@ -1,142 +1,142 @@
|
|||||||
// Bind Listeners
|
// Bind Listeners
|
||||||
|
|
||||||
$("input[name='font-select']").change(function(){
|
$("input[name='font-select']").change(function(){
|
||||||
let $choice = $(this).val();
|
let $choice = $(this).val()
|
||||||
set_font($choice);
|
set_font($choice)
|
||||||
});
|
})
|
||||||
|
|
||||||
$("input[name='font-size']").change(function(){
|
$("input[name='font-size']").change(function(){
|
||||||
let $choice = $(this).val();
|
let $choice = $(this).val()
|
||||||
set_font_size($choice);
|
set_font_size($choice)
|
||||||
});
|
})
|
||||||
|
|
||||||
$("input[name='bg-select']").change(function(){
|
$("input[name='bg-select']").change(function(){
|
||||||
let $choice = $(this).val();
|
let $choice = $(this).val()
|
||||||
set_bg_colour($choice);
|
set_bg_colour($choice)
|
||||||
});
|
})
|
||||||
|
|
||||||
$(".bg-select-area").click(function(event){
|
$(".bg-select-area").click(function(event){
|
||||||
$(this).find("input[name='bg-select']").prop("checked", true).change();
|
$(this).find("input[name='bg-select']").prop("checked", true).change()
|
||||||
});
|
})
|
||||||
|
|
||||||
$("#btn-toggle-navigator").click(function(event){
|
$("#btn-toggle-navigator").click(function(event){
|
||||||
check_answered();
|
check_answered()
|
||||||
update_navigator();
|
update_navigator()
|
||||||
if ($quiz_navigator.is(":hidden")) {
|
if ($quiz_navigator.is(":hidden")) {
|
||||||
if ($quiz_settings.is(":visible")) {
|
if ($quiz_settings.is(":visible")) {
|
||||||
toggle_settings = true;
|
toggle_settings = true
|
||||||
$quiz_settings.fadeOut();
|
$quiz_settings.fadeOut()
|
||||||
}
|
}
|
||||||
$quiz_render.fadeOut();
|
$quiz_render.fadeOut()
|
||||||
$quiz_navigator.fadeIn();
|
$quiz_navigator.fadeIn()
|
||||||
$(".navigator-text").fadeIn();
|
$(".navigator-text").fadeIn()
|
||||||
$(".review-text").fadeOut();
|
$(".review-text").fadeOut()
|
||||||
toggle_navigator = false;
|
toggle_navigator = false
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
} else {
|
} else {
|
||||||
$quiz_navigator.fadeOut();
|
$quiz_navigator.fadeOut()
|
||||||
if (toggle_settings) {
|
if (toggle_settings) {
|
||||||
$quiz_settings.fadeIn();
|
$quiz_settings.fadeIn()
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
toggle_settings = false;
|
toggle_settings = false
|
||||||
} else {
|
} else {
|
||||||
$quiz_render.fadeIn();
|
$quiz_render.fadeIn()
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$("#btn-toggle-settings").click(function(event){
|
$("#btn-toggle-settings").click(function(event){
|
||||||
if (($quiz_settings).is(":hidden")) {
|
if (($quiz_settings).is(":hidden")) {
|
||||||
if ($quiz_navigator.is(":visible")) {
|
if ($quiz_navigator.is(":visible")) {
|
||||||
toggle_navigator = true;
|
toggle_navigator = true
|
||||||
$quiz_navigator.fadeOut();
|
$quiz_navigator.fadeOut()
|
||||||
}
|
}
|
||||||
$quiz_render.fadeOut();
|
$quiz_render.fadeOut()
|
||||||
$quiz_settings.fadeIn();
|
$quiz_settings.fadeIn()
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
toggle_settings = false;
|
toggle_settings = false
|
||||||
} else {
|
} else {
|
||||||
$quiz_settings.fadeOut();
|
$quiz_settings.fadeOut()
|
||||||
if (toggle_navigator) {
|
if (toggle_navigator) {
|
||||||
$quiz_navigator.fadeIn();
|
$quiz_navigator.fadeIn()
|
||||||
toggle_navigator = false;
|
toggle_navigator = false
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
} else {
|
} else {
|
||||||
$quiz_render.fadeIn();
|
$quiz_render.fadeIn()
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$(".btn-quiz-return").click(function(event){
|
$(".btn-quiz-return").click(function(event){
|
||||||
$quiz_navigator.fadeOut();
|
$quiz_navigator.fadeOut()
|
||||||
$quiz_settings.fadeOut();
|
$quiz_settings.fadeOut()
|
||||||
$quiz_render.fadeIn();
|
$quiz_render.fadeIn()
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
toggle_settings = false;
|
toggle_settings = false
|
||||||
toggle_navigator = false;
|
toggle_navigator = false
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$(".btn-dummy").click(function(event){
|
$(".btn-dummy").click(function(event){
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$("#navigator-container").on("click", ".q-navigator-button", function(event){
|
$("#navigator-container").on("click", ".q-navigator-button", function(event){
|
||||||
check_answered();
|
check_answered()
|
||||||
update_navigator();
|
update_navigator()
|
||||||
current_question = parseInt($(this).prop("name"));
|
current_question = parseInt($(this).prop("name"))
|
||||||
$quiz_navigator.fadeOut();
|
$quiz_navigator.fadeOut()
|
||||||
$quiz_render.fadeIn();
|
$quiz_render.fadeIn()
|
||||||
$question_title.focus();
|
$question_title.focus()
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
toggle_navigator = false;
|
toggle_navigator = false
|
||||||
toggle_settings = false;
|
toggle_settings = false
|
||||||
render_question();
|
render_question()
|
||||||
check_flag();
|
check_flag()
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$(".q-question-nav").click(function(event){
|
$(".q-question-nav").click(function(event){
|
||||||
check_answered();
|
check_answered()
|
||||||
update_navigator();
|
update_navigator()
|
||||||
if ($(this).prop("id") == "q-nav-next") {
|
if ($(this).prop("id") == "q-nav-next") {
|
||||||
if (current_question < questions.length) {
|
if (current_question < questions.length) {
|
||||||
current_question ++;
|
current_question ++
|
||||||
}
|
}
|
||||||
} else if ($(this).prop("id") == "q-nav-prev") {
|
} else if ($(this).prop("id") == "q-nav-prev") {
|
||||||
if (current_question > 0) {
|
if (current_question > 0) {
|
||||||
current_question --;
|
current_question --
|
||||||
}
|
}
|
||||||
} else if ($(this).hasClass("q-navigator-button")) {
|
} else if ($(this).hasClass("q-navigator-button")) {
|
||||||
current_question = $(this).prop("name");
|
current_question = $(this).prop("name")
|
||||||
$quiz_render.fadeIn();
|
$quiz_render.fadeIn()
|
||||||
$quiz_navigator.fadeOut();
|
$quiz_navigator.fadeOut()
|
||||||
toggle_navigator = false;
|
toggle_navigator = false
|
||||||
toggle_settings = false;
|
toggle_settings = false
|
||||||
}
|
}
|
||||||
render_question();
|
render_question()
|
||||||
check_flag();
|
check_flag()
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$("#q-nav-flag").click(function(event){
|
$("#q-nav-flag").click(function(event){
|
||||||
if (question_status[current_question] != 1) {
|
if (question_status[current_question] != 1) {
|
||||||
question_status[current_question] = 1;
|
question_status[current_question] = 1
|
||||||
$(this).removeClass().addClass("btn btn-warning");
|
$(this).removeClass().addClass("btn btn-warning")
|
||||||
$(this).prop("title", "Question Flagged for revision. Click to un-flag.");
|
$(this).prop("title", "Question Flagged for revision. Click to un-flag.")
|
||||||
} else {
|
} else {
|
||||||
question_status[current_question] = 0;
|
question_status[current_question] = 0
|
||||||
$(this).removeClass().addClass("btn btn-secondary");
|
$(this).removeClass().addClass("btn btn-secondary")
|
||||||
$(this).prop("title", "Question Un-Flagged. Click to flag for revision.");
|
$(this).prop("title", "Question Un-Flagged. Click to flag for revision.")
|
||||||
}
|
}
|
||||||
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
window.localStorage.setItem('question_status', JSON.stringify(question_status))
|
||||||
update_navigator();
|
update_navigator()
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$("#btn-start-quiz").click(function(event){
|
$("#btn-start-quiz").click(function(event){
|
||||||
|
|
||||||
@ -146,29 +146,29 @@ $("#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()
|
||||||
$("#quiz-navigator").fadeOut();
|
$("#quiz-navigator").fadeOut()
|
||||||
$(".quiz-start-text").fadeOut();
|
$(".quiz-start-text").fadeOut()
|
||||||
time_limit = response.time_limit;
|
time_limit = response.time_limit
|
||||||
start_time = response.start_time;
|
start_time = response.start_time
|
||||||
questions = response.questions;
|
questions = response.questions
|
||||||
total_questions = questions.length;
|
total_questions = questions.length
|
||||||
window.localStorage.setItem('questions', JSON.stringify(questions));
|
window.localStorage.setItem('questions', JSON.stringify(questions))
|
||||||
window.localStorage.setItem('start_time', JSON.stringify(start_time));
|
window.localStorage.setItem('start_time', JSON.stringify(start_time))
|
||||||
window.localStorage.setItem('time_limit', JSON.stringify(time_limit));
|
window.localStorage.setItem('time_limit', JSON.stringify(time_limit))
|
||||||
render_question();
|
render_question()
|
||||||
build_navigator();
|
build_navigator()
|
||||||
check_flag();
|
check_flag()
|
||||||
if (time_limit != 'null' && time_limit != null) {
|
if (time_limit != 'null' && time_limit != null) {
|
||||||
$("#q-timer-widget").fadeIn();
|
$("#q-timer-widget").fadeIn()
|
||||||
time_remaining = get_time_remaining();
|
time_remaining = get_time_remaining()
|
||||||
clock = setInterval(timer, 1000);
|
clock = setInterval(timer, 1000)
|
||||||
}
|
}
|
||||||
if (response.time_adjustment > 0) {
|
if (response.time_adjustment > 0) {
|
||||||
const $alert = $("#alert-box");
|
const $alert = $("#alert-box")
|
||||||
$alert.html(
|
$alert.html(
|
||||||
`<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
`<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill" title="Alert"></i>
|
<i class="bi bi-exclamation-triangle-fill" title="Alert"></i>
|
||||||
@ -176,50 +176,50 @@ $("#btn-start-quiz").click(function(event){
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
);
|
)
|
||||||
$alert.focus();
|
$alert.focus()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function(response) {
|
error: function(response) {
|
||||||
error_response(response);
|
error_response(response)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$("#quiz-question-options").on("change", ".quiz-option", function(event){
|
$("#quiz-question-options").on("change", ".quiz-option", function(event){
|
||||||
$name = parseInt($(this).prop("name"));
|
$name = parseInt($(this).prop("name"))
|
||||||
$value = $(this).prop("value");
|
$value = $(this).prop("value")
|
||||||
answers[$name] = $value;
|
answers[$name] = $value
|
||||||
window.localStorage.setItem('answers', JSON.stringify(answers));
|
window.localStorage.setItem('answers', JSON.stringify(answers))
|
||||||
});
|
})
|
||||||
|
|
||||||
$("#q-review-answers").click(function(event){
|
$("#q-review-answers").click(function(event){
|
||||||
check_answered();
|
check_answered()
|
||||||
update_navigator();
|
update_navigator()
|
||||||
if ($quiz_navigator.is(":hidden")) {
|
if ($quiz_navigator.is(":hidden")) {
|
||||||
if ($quiz_settings.is(":visible")) {
|
if ($quiz_settings.is(":visible")) {
|
||||||
toggle_settings = true;
|
toggle_settings = true
|
||||||
$quiz_settings.fadeOut();
|
$quiz_settings.fadeOut()
|
||||||
}
|
}
|
||||||
$quiz_render.fadeOut();
|
$quiz_render.fadeOut()
|
||||||
$quiz_navigator.fadeIn();
|
$quiz_navigator.fadeIn()
|
||||||
$(".navigator-text").fadeOut();
|
$(".navigator-text").fadeOut()
|
||||||
$(".review-text").fadeIn();
|
$(".review-text").fadeIn()
|
||||||
toggle_navigator = false;
|
toggle_navigator = false
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
} else {
|
} else {
|
||||||
$quiz_navigator.fadeOut();
|
$quiz_navigator.fadeOut()
|
||||||
if (toggle_settings) {
|
if (toggle_settings) {
|
||||||
$quiz_settings.fadeIn();
|
$quiz_settings.fadeIn()
|
||||||
toggle_settings = false;
|
toggle_settings = false
|
||||||
} else {
|
} else {
|
||||||
$quiz_render.fadeIn();
|
$quiz_render.fadeIn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
$(".quiz-button-submit").click(function(event){
|
$(".quiz-button-submit").click(function(event){
|
||||||
let submission = {
|
let submission = {
|
||||||
@ -233,16 +233,16 @@ $(".quiz-button-submit").click(function(event){
|
|||||||
data: JSON.stringify(submission),
|
data: JSON.stringify(submission),
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear()
|
||||||
window.location.href = `/result/`;
|
window.location.href = `/result/`
|
||||||
},
|
},
|
||||||
error: function(response) {
|
error: function(response) {
|
||||||
error_response(response);
|
error_response(response)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
|
|
||||||
@ -251,17 +251,17 @@ function set_font(value = 'osdefault') {
|
|||||||
|
|
||||||
for (let i = 0; i < font_styles.length; i ++) {
|
for (let i = 0; i < font_styles.length; i ++) {
|
||||||
if (font_styles[i] != value) {
|
if (font_styles[i] != value) {
|
||||||
$("body").removeClass( `q-f-${font_styles[i]}` );
|
$("body").removeClass( `q-f-${font_styles[i]}` )
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (value != 'osdefault') {
|
if (value != 'osdefault') {
|
||||||
$("body").addClass(`q-f-${value}`);
|
$("body").addClass(`q-f-${value}`)
|
||||||
};
|
}
|
||||||
|
|
||||||
display_settings['font-select'] = value;
|
display_settings['font-select'] = value
|
||||||
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings))
|
||||||
$('input[name="font-select"][value="' + value + '"]').prop('checked', true);
|
$('input[name="font-select"][value="' + value + '"]').prop('checked', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function set_font_size(value = '14pt') {
|
function set_font_size(value = '14pt') {
|
||||||
@ -269,17 +269,17 @@ function set_font_size(value = '14pt') {
|
|||||||
|
|
||||||
for (let i = 0; i < font_sizes.length; i ++) {
|
for (let i = 0; i < font_sizes.length; i ++) {
|
||||||
if (font_sizes[i] != value) {
|
if (font_sizes[i] != value) {
|
||||||
$("body").removeClass( `q-f-${font_sizes[i]}` );
|
$("body").removeClass( `q-f-${font_sizes[i]}` )
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (value != '14pt') {
|
if (value != '14pt') {
|
||||||
$("body").addClass(`q-f-${value}`);
|
$("body").addClass(`q-f-${value}`)
|
||||||
};
|
}
|
||||||
|
|
||||||
display_settings['font-size'] = value;
|
display_settings['font-size'] = value
|
||||||
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings))
|
||||||
$('input[name="font-size"][value="' + value + '"]').prop('checked', true);
|
$('input[name="font-size"][value="' + value + '"]').prop('checked', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function set_bg_colour(value = 'bg-light') {
|
function set_bg_colour(value = 'bg-light') {
|
||||||
@ -287,34 +287,34 @@ function set_bg_colour(value = 'bg-light') {
|
|||||||
|
|
||||||
for (let i = 0; i < backgrounds.length; i ++) {
|
for (let i = 0; i < backgrounds.length; i ++) {
|
||||||
if (backgrounds[i] != value) {
|
if (backgrounds[i] != value) {
|
||||||
$("body").removeClass(backgrounds[i]);
|
$("body").removeClass(backgrounds[i])
|
||||||
if (backgrounds[i] == 'bg-dark') {
|
if (backgrounds[i] == 'bg-dark') {
|
||||||
$("body").removeClass('text-light');
|
$("body").removeClass('text-light')
|
||||||
};
|
}
|
||||||
if (backgrounds[i] == 'alert-primary' || backgrounds[i] == 'alert-secondary' || backgrounds[i] == 'alert-dark') {
|
if (backgrounds[i] == 'alert-primary' || backgrounds[i] == 'alert-secondary' || backgrounds[i] == 'alert-dark') {
|
||||||
$("body").removeClass('text-dark');
|
$("body").removeClass('text-dark')
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
$("body").addClass(value);
|
$("body").addClass(value)
|
||||||
if (value == 'bg-dark') {
|
if (value == 'bg-dark') {
|
||||||
$("body").addClass('text-light');
|
$("body").addClass('text-light')
|
||||||
};
|
}
|
||||||
if (value == 'alert-primary' || value == 'alert-secondary' || value == 'alert-dark') {
|
if (value == 'alert-primary' || value == 'alert-secondary' || value == 'alert-dark') {
|
||||||
$("body").addClass('text-dark');
|
$("body").addClass('text-dark')
|
||||||
};
|
}
|
||||||
|
|
||||||
display_settings['bg-select'] = value;
|
display_settings['bg-select'] = value
|
||||||
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings))
|
||||||
$('input[name="bg-select"][value="' + value + '"]').prop('checked', true);
|
$('input[name="bg-select"][value="' + value + '"]').prop('checked', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_settings_from_storage() {
|
function get_settings_from_storage() {
|
||||||
let display_settings = window.localStorage.getItem('display_settings')
|
let display_settings = window.localStorage.getItem('display_settings')
|
||||||
if (display_settings != null) {
|
if (display_settings != null) {
|
||||||
return JSON.parse(display_settings);
|
return JSON.parse(display_settings)
|
||||||
};
|
}
|
||||||
return {
|
return {
|
||||||
'font-select': 'osdefault',
|
'font-select': 'osdefault',
|
||||||
'font-size': '14pt',
|
'font-size': '14pt',
|
||||||
@ -323,133 +323,133 @@ function get_settings_from_storage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function apply_settings(settings) {
|
function apply_settings(settings) {
|
||||||
set_font(settings['font-select']);
|
set_font(settings['font-select'])
|
||||||
set_font_size(settings['font-size']);
|
set_font_size(settings['font-size'])
|
||||||
set_bg_colour(settings['bg-select']);
|
set_bg_colour(settings['bg-select'])
|
||||||
}
|
}
|
||||||
|
|
||||||
function render_question() {
|
function render_question() {
|
||||||
if (current_question == 0) {
|
if (current_question == 0) {
|
||||||
$nav_prev.addClass('disabled');
|
$nav_prev.addClass('disabled')
|
||||||
}
|
}
|
||||||
if (current_question == questions.length - 1) {
|
if (current_question == questions.length - 1) {
|
||||||
$nav_next.addClass('disabled');
|
$nav_next.addClass('disabled')
|
||||||
}
|
}
|
||||||
if ($nav_prev.hasClass('disabled') && current_question > 0) {
|
if ($nav_prev.hasClass('disabled') && current_question > 0) {
|
||||||
$nav_prev.removeClass('disabled');
|
$nav_prev.removeClass('disabled')
|
||||||
}
|
}
|
||||||
if ($nav_next.hasClass('disabled') && current_question < questions.length - 1) {
|
if ($nav_next.hasClass('disabled') && current_question < questions.length - 1) {
|
||||||
$nav_next.removeClass('disabled');
|
$nav_next.removeClass('disabled')
|
||||||
}
|
}
|
||||||
var question = questions[current_question];
|
var question = questions[current_question]
|
||||||
let header_text = question.question_header;
|
let header_text = question.question_header
|
||||||
var block_length = 0;
|
var block_length = 0
|
||||||
if ('block_length' in question) {
|
if ('block_length' in question) {
|
||||||
block_length = question['block_length'];
|
block_length = question['block_length']
|
||||||
};
|
}
|
||||||
var block_q_no = 0;
|
var block_q_no = 0
|
||||||
if ('block_q_no' in question) {
|
if ('block_q_no' in question) {
|
||||||
block_q_no = question['block_q_no'];
|
block_q_no = question['block_q_no']
|
||||||
}
|
}
|
||||||
let remaining_qs = (block_length - block_q_no).toString();
|
let remaining_qs = (block_length - block_q_no).toString()
|
||||||
if (block_length - block_q_no > 1) {
|
if (block_length - block_q_no > 1) {
|
||||||
remaining_qs += ' questions';
|
remaining_qs += ' questions'
|
||||||
} else {
|
} else {
|
||||||
remaining_qs += ' question';
|
remaining_qs += ' question'
|
||||||
}
|
}
|
||||||
header_text = header_text.replace('<block_remaining_questions>', remaining_qs);
|
header_text = header_text.replace('<block_remaining_questions>', remaining_qs)
|
||||||
$question_header.html(header_text);
|
$question_header.html(header_text)
|
||||||
$question_text.html(question.text);
|
$question_text.html(question.text)
|
||||||
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`);
|
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`)
|
||||||
|
|
||||||
var q_no = question['q_no'];
|
var q_no = question['q_no']
|
||||||
var options = question.options;
|
var options = question.options
|
||||||
var options_output = '';
|
var options_output = ''
|
||||||
for (let i = 0; i < options.length; i ++) {
|
for (let i = 0; i < options.length; i ++) {
|
||||||
var add_checked = ''
|
var add_checked = ''
|
||||||
if (q_no in answers) {
|
if (q_no in answers) {
|
||||||
if (answers[q_no] == options[i][0]) {
|
if (answers[q_no] == options[i][0]) {
|
||||||
add_checked = 'checked';
|
add_checked = 'checked'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
options_output += `<div class="form-check">
|
options_output += `<div class="form-check">
|
||||||
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i][0]}" ${add_checked}>
|
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i][0]}" ${add_checked}>
|
||||||
<label for="q${current_question}-${i}" class="form-check-label">${options[i][1]}</label>
|
<label for="q${current_question}-${i}" class="form-check-label">${options[i][1]}</label>
|
||||||
</div>`;
|
</div>`
|
||||||
}
|
}
|
||||||
$question_options.html(options_output);
|
$question_options.html(options_output)
|
||||||
let skipped = count_questions(-1);
|
let skipped = count_questions(-1)
|
||||||
let answered = count_questions(2);
|
let answered = count_questions(2)
|
||||||
let flagged = count_questions(1);
|
let flagged = count_questions(1)
|
||||||
|
|
||||||
$progress_skipped.prop('title', `Skipped: ${skipped}`);
|
$progress_skipped.prop('title', `Skipped: ${skipped}`)
|
||||||
$progress_skipped.prop('aria-valuenow', skipped);
|
$progress_skipped.prop('aria-valuenow', skipped)
|
||||||
$progress_skipped.css('width', `${skipped}%`);
|
$progress_skipped.css('width', `${skipped}%`)
|
||||||
$skipped_count.text(`Skipped: ${skipped}`);
|
$skipped_count.text(`Skipped: ${skipped}`)
|
||||||
if (skipped < 1) {
|
if (skipped < 1) {
|
||||||
$skipped_count.fadeOut()
|
$skipped_count.fadeOut()
|
||||||
} else {
|
} else {
|
||||||
$skipped_count.fadeIn()
|
$skipped_count.fadeIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
$progress_flagged.prop('title', `Flagged: ${flagged}`);
|
$progress_flagged.prop('title', `Flagged: ${flagged}`)
|
||||||
$progress_flagged.prop('aria-valuenow', flagged);
|
$progress_flagged.prop('aria-valuenow', flagged)
|
||||||
$progress_flagged.css('width', `${flagged}%`);
|
$progress_flagged.css('width', `${flagged}%`)
|
||||||
$flagged_count.text(`Flagged: ${flagged}`);
|
$flagged_count.text(`Flagged: ${flagged}`)
|
||||||
if (flagged < 1) {
|
if (flagged < 1) {
|
||||||
$flagged_count.fadeOut()
|
$flagged_count.fadeOut()
|
||||||
} else {
|
} else {
|
||||||
$flagged_count.fadeIn()
|
$flagged_count.fadeIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
$progress_answered.prop('title', `Answered: ${answered}`);
|
$progress_answered.prop('title', `Answered: ${answered}`)
|
||||||
$progress_answered.prop('aria-valuenow', answered);
|
$progress_answered.prop('aria-valuenow', answered)
|
||||||
$progress_answered.css('width', `${answered}%`);
|
$progress_answered.css('width', `${answered}%`)
|
||||||
$answered_count.text(`Answered: ${answered}`);
|
$answered_count.text(`Answered: ${answered}`)
|
||||||
if (answered < 1) {
|
if (answered < 1) {
|
||||||
$answered_count.fadeOut()
|
$answered_count.fadeOut()
|
||||||
} else {
|
} else {
|
||||||
$answered_count.fadeIn()
|
$answered_count.fadeIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
$question_title.focus();
|
$question_title.focus()
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function check_answered() {
|
function check_answered() {
|
||||||
var question = questions[current_question];
|
var question = questions[current_question]
|
||||||
var name = question.q_no;
|
var name = question.q_no
|
||||||
if (question_status[current_question] == 0 || question_status[current_question] == -1) {
|
if (question_status[current_question] == 0 || question_status[current_question] == -1) {
|
||||||
if (!$(`input[name='${name}']:checked`).val()) {
|
if (!$(`input[name='${name}']:checked`).val()) {
|
||||||
question_status[current_question] = -1;
|
question_status[current_question] = -1
|
||||||
} else {
|
} else {
|
||||||
question_status[current_question] = 2;
|
question_status[current_question] = 2
|
||||||
}
|
}
|
||||||
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
window.localStorage.setItem('question_status', JSON.stringify(question_status))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function check_flag() {
|
function check_flag() {
|
||||||
if (!(current_question in question_status)) {
|
if (!(current_question in question_status)) {
|
||||||
question_status[current_question] = 0;
|
question_status[current_question] = 0
|
||||||
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
window.localStorage.setItem('question_status', JSON.stringify(question_status))
|
||||||
}
|
}
|
||||||
switch (question_status[current_question]) {
|
switch (question_status[current_question]) {
|
||||||
case -1:
|
case -1:
|
||||||
$nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped');
|
$nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped')
|
||||||
$nav_flag.prop("title", "Question Incomplete. Click to flag for revision.");
|
$nav_flag.prop("title", "Question Incomplete. Click to flag for revision.")
|
||||||
break;
|
break
|
||||||
case 1:
|
case 1:
|
||||||
$nav_flag.removeClass().addClass('btn btn-warning');
|
$nav_flag.removeClass().addClass('btn btn-warning')
|
||||||
$nav_flag.prop("title", "Question Flagged for revision. Click to un-flag.");
|
$nav_flag.prop("title", "Question Flagged for revision. Click to un-flag.")
|
||||||
break;
|
break
|
||||||
case 2:
|
case 2:
|
||||||
$nav_flag.removeClass().addClass('btn btn-success');
|
$nav_flag.removeClass().addClass('btn btn-success')
|
||||||
$nav_flag.prop("title", "Question Answered. Click to flag for revision.");
|
$nav_flag.prop("title", "Question Answered. Click to flag for revision.")
|
||||||
break;
|
break
|
||||||
default:
|
default:
|
||||||
$nav_flag.removeClass().addClass('btn btn-secondary');
|
$nav_flag.removeClass().addClass('btn btn-secondary')
|
||||||
$nav_flag.prop("title", "Question Un-Flagged. Click to flag for revision.");
|
$nav_flag.prop("title", "Question Un-Flagged. Click to flag for revision.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -457,31 +457,31 @@ function build_navigator() {
|
|||||||
$nav_container.html('')
|
$nav_container.html('')
|
||||||
var output = ''
|
var output = ''
|
||||||
for (let i = 0; i < questions.length; i ++) {
|
for (let i = 0; i < questions.length; i ++) {
|
||||||
let add_class, add_href, add_status = '';
|
let add_class, add_href, add_status = ''
|
||||||
switch (question_status[i]) {
|
switch (question_status[i]) {
|
||||||
case -1:
|
case -1:
|
||||||
add_class = 'btn-danger progress-bar-striped';
|
add_class = 'btn-danger progress-bar-striped'
|
||||||
add_href = 'href="#"';
|
add_href = 'href="#"'
|
||||||
add_status = 'Incomplete';
|
add_status = 'Incomplete'
|
||||||
break;
|
break
|
||||||
case 1:
|
case 1:
|
||||||
add_class = 'btn-warning';
|
add_class = 'btn-warning'
|
||||||
add_href = 'href="#"';
|
add_href = 'href="#"'
|
||||||
add_status = 'Flagged';
|
add_status = 'Flagged'
|
||||||
break;
|
break
|
||||||
case 2:
|
case 2:
|
||||||
add_class = 'btn-success';
|
add_class = 'btn-success'
|
||||||
add_href = 'href="#"';
|
add_href = 'href="#"'
|
||||||
add_status = 'Answered';
|
add_status = 'Answered'
|
||||||
break;
|
break
|
||||||
default:
|
default:
|
||||||
add_class = 'btn-secondary disabled';
|
add_class = 'btn-secondary disabled'
|
||||||
add_href = '';
|
add_href = ''
|
||||||
add_status = 'Unseen';
|
add_status = 'Unseen'
|
||||||
}
|
}
|
||||||
output += `<a ${add_href} class="q-navigator-button btn ${add_class}" name=${i} title="Question ${i+1}: ${add_status}">Q${i + 1}</a>`;
|
output += `<a ${add_href} class="q-navigator-button btn ${add_class}" name=${i} title="Question ${i+1}: ${add_status}">Q${i + 1}</a>`
|
||||||
}
|
}
|
||||||
$nav_container.html(output);
|
$nav_container.html(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
function update_navigator() {
|
function update_navigator() {
|
||||||
@ -489,162 +489,162 @@ function update_navigator() {
|
|||||||
if (current_question in question_status) {
|
if (current_question in question_status) {
|
||||||
switch (question_status[current_question]) {
|
switch (question_status[current_question]) {
|
||||||
case -1:
|
case -1:
|
||||||
button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped");
|
button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped")
|
||||||
button.prop("title", `Question ${current_question + 1}: Incomplete`);
|
button.prop("title", `Question ${current_question + 1}: Incomplete`)
|
||||||
break;
|
break
|
||||||
case 1:
|
case 1:
|
||||||
button.removeClass().addClass("q-navigator-button btn btn-warning");
|
button.removeClass().addClass("q-navigator-button btn btn-warning")
|
||||||
button.prop("title", `Question ${current_question + 1}: Flagged`);
|
button.prop("title", `Question ${current_question + 1}: Flagged`)
|
||||||
break;
|
break
|
||||||
case 2:
|
case 2:
|
||||||
button.removeClass().addClass("q-navigator-button btn btn-success");
|
button.removeClass().addClass("q-navigator-button btn btn-success")
|
||||||
button.prop("title", `Question ${current_question + 1}: Answered`);
|
button.prop("title", `Question ${current_question + 1}: Answered`)
|
||||||
break;
|
break
|
||||||
default:
|
default:
|
||||||
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled");
|
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled")
|
||||||
button.prop("title", `Question ${current_question + 1}: Unseen`);
|
button.prop("title", `Question ${current_question + 1}: Unseen`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
$("#btn-start-quiz").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()
|
||||||
$("#quiz-navigator").fadeOut();
|
$("#quiz-navigator").fadeOut()
|
||||||
$(".quiz-start-text").fadeOut();
|
$(".quiz-start-text").fadeOut()
|
||||||
|
|
||||||
questions = JSON.parse(window.localStorage.getItem('questions'));
|
questions = JSON.parse(window.localStorage.getItem('questions'))
|
||||||
total_questions = questions.length;
|
total_questions = questions.length
|
||||||
start_time = window.localStorage.getItem('start_time');
|
start_time = window.localStorage.getItem('start_time')
|
||||||
time_limit = window.localStorage.getItem('time_limit');
|
time_limit = window.localStorage.getItem('time_limit')
|
||||||
|
|
||||||
let get_answers = window.localStorage.getItem('answers');
|
let get_answers = window.localStorage.getItem('answers')
|
||||||
if (get_answers != null) {
|
if (get_answers != null) {
|
||||||
answers = JSON.parse(get_answers);
|
answers = JSON.parse(get_answers)
|
||||||
}
|
}
|
||||||
|
|
||||||
let get_status = window.localStorage.getItem('question_status');
|
let get_status = window.localStorage.getItem('question_status')
|
||||||
if (get_status != null) {
|
if (get_status != null) {
|
||||||
question_status = JSON.parse(get_status);
|
question_status = JSON.parse(get_status)
|
||||||
}
|
}
|
||||||
|
|
||||||
render_question();
|
render_question()
|
||||||
build_navigator();
|
build_navigator()
|
||||||
check_flag();
|
check_flag()
|
||||||
if (time_limit != 'null' && time_limit != null) {
|
if (time_limit != 'null' && time_limit != null) {
|
||||||
$("#q-timer-widget").fadeIn();
|
$("#q-timer-widget").fadeIn()
|
||||||
time_remaining = get_time_remaining();
|
time_remaining = get_time_remaining()
|
||||||
clock = setInterval(timer, 1000);
|
clock = setInterval(timer, 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function check_started() {
|
function check_started() {
|
||||||
let questions = window.localStorage.getItem('questions');
|
let questions = window.localStorage.getItem('questions')
|
||||||
let time_limit = window.localStorage.getItem('time_limit');
|
let time_limit = window.localStorage.getItem('time_limit')
|
||||||
let start_time = window.localStorage.getItem('start_time')
|
let start_time = window.localStorage.getItem('start_time')
|
||||||
if (questions != null && start_time != null && time_limit != null) {
|
if (questions != null && start_time != null && time_limit != null) {
|
||||||
start();
|
start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_time_remaining() {
|
function get_time_remaining() {
|
||||||
var end_time = new Date(time_limit).getTime();
|
var end_time = new Date(time_limit).getTime()
|
||||||
var _start_time = new Date().getTime();
|
var _start_time = new Date().getTime()
|
||||||
return end_time - _start_time;
|
return end_time - _start_time
|
||||||
}
|
}
|
||||||
|
|
||||||
function timer() {
|
function timer() {
|
||||||
var hours = Math.floor((time_remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
var hours = Math.floor((time_remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||||
var minutes = Math.floor((time_remaining % (1000 * 60 * 60)) / (1000 * 60));
|
var minutes = Math.floor((time_remaining % (1000 * 60 * 60)) / (1000 * 60))
|
||||||
var seconds = Math.floor((time_remaining % (1000 * 60)) / 1000);
|
var seconds = Math.floor((time_remaining % (1000 * 60)) / 1000)
|
||||||
|
|
||||||
if (time_remaining > 0) {
|
if (time_remaining > 0) {
|
||||||
var timer_display = '';
|
var timer_display = ''
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
timer_display = `${hours.toString()}:`;
|
timer_display = `${hours.toString()}:`
|
||||||
}
|
}
|
||||||
if (minutes > 0 || hours > 0) {
|
if (minutes > 0 || hours > 0) {
|
||||||
if (minutes < 10) {
|
if (minutes < 10) {
|
||||||
timer_display += `0${minutes.toString()}:`;
|
timer_display += `0${minutes.toString()}:`
|
||||||
} else {
|
} else {
|
||||||
timer_display += `${minutes.toString()}:`;
|
timer_display += `${minutes.toString()}:`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seconds < 10) {
|
if (seconds < 10) {
|
||||||
timer_display += `0${seconds.toString()}`;
|
timer_display += `0${seconds.toString()}`
|
||||||
} else {
|
} else {
|
||||||
timer_display += seconds.toString();
|
timer_display += seconds.toString()
|
||||||
}
|
}
|
||||||
$timer.html(timer_display);
|
$timer.html(timer_display)
|
||||||
time_remaining -= 1000
|
time_remaining -= 1000
|
||||||
} else {
|
} else {
|
||||||
$timer.html('Expired');
|
$timer.html('Expired')
|
||||||
clearInterval(clock);
|
clearInterval(clock)
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
$quiz_render.fadeOut();
|
$quiz_render.fadeOut()
|
||||||
$quiz_navigator.fadeOut();
|
$quiz_navigator.fadeOut()
|
||||||
$quiz_timeout.fadeIn();
|
$quiz_timeout.fadeIn()
|
||||||
$("#btn-toggle-navigator").addClass('disabled');
|
$("#btn-toggle-navigator").addClass('disabled')
|
||||||
$("#btn-toggle-settings").addClass('disabled')
|
$("#btn-toggle-settings").addClass('disabled')
|
||||||
}
|
}
|
||||||
|
|
||||||
function count_questions(status) {
|
function count_questions(status) {
|
||||||
output = 0;
|
output = 0
|
||||||
for (let i = 0; i < Object.keys(question_status).length; i++) {
|
for (let i = 0; i < Object.keys(question_status).length; i++) {
|
||||||
key = Object.keys(question_status)[i];
|
key = Object.keys(question_status)[i]
|
||||||
if (question_status[key] == status){
|
if (question_status[key] == status){
|
||||||
output ++;
|
output ++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output;
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variable Definitions
|
// Variable Definitions
|
||||||
|
|
||||||
const id = window.localStorage.getItem('id');
|
const id = window.localStorage.getItem('id')
|
||||||
|
|
||||||
var current_question = 0;
|
var current_question = 0
|
||||||
var total_questions = 0;
|
var total_questions = 0
|
||||||
var question_status = {};
|
var question_status = {}
|
||||||
var answers = {};
|
var answers = {}
|
||||||
var questions = [];
|
var questions = []
|
||||||
var time_limit, start_time, time_remaining;
|
var time_limit, start_time, time_remaining
|
||||||
|
|
||||||
var display_settings = get_settings_from_storage();
|
var display_settings = get_settings_from_storage()
|
||||||
|
|
||||||
const $quiz_settings = $("#quiz-settings");
|
const $quiz_settings = $("#quiz-settings")
|
||||||
const $quiz_navigator = $("#quiz-navigator");
|
const $quiz_navigator = $("#quiz-navigator")
|
||||||
const $quiz_render = $("#quiz-render");
|
const $quiz_render = $("#quiz-render")
|
||||||
const $quiz_timeout = $("#quiz-timeout");
|
const $quiz_timeout = $("#quiz-timeout")
|
||||||
const $nav_flag = $("#q-nav-flag");
|
const $nav_flag = $("#q-nav-flag")
|
||||||
const $nav_next = $("#q-nav-next");
|
const $nav_next = $("#q-nav-next")
|
||||||
const $nav_prev = $("#q-nav-prev");
|
const $nav_prev = $("#q-nav-prev")
|
||||||
const $nav_container = $("#navigator-container");
|
const $nav_container = $("#navigator-container")
|
||||||
const $timer = $("#q-timer-display");
|
const $timer = $("#q-timer-display")
|
||||||
var clock
|
var clock
|
||||||
|
|
||||||
var toggle_settings = false;
|
var toggle_settings = false
|
||||||
var toggle_navigator = false;
|
var toggle_navigator = false
|
||||||
|
|
||||||
const $question_title = $("#quiz-question-title");
|
const $question_title = $("#quiz-question-title")
|
||||||
const $question_header = $("#quiz-question-header");
|
const $question_header = $("#quiz-question-header")
|
||||||
const $question_text = $("#quiz-question-text");
|
const $question_text = $("#quiz-question-text")
|
||||||
const $question_options = $("#quiz-question-options");
|
const $question_options = $("#quiz-question-options")
|
||||||
|
|
||||||
const $progress_skipped = $("#skipped-bar");
|
const $progress_skipped = $("#skipped-bar")
|
||||||
const $progress_answered = $("#answered-bar");
|
const $progress_answered = $("#answered-bar")
|
||||||
const $progress_flagged = $("#flagged-bar");
|
const $progress_flagged = $("#flagged-bar")
|
||||||
const $skipped_count = $("#skipped-count");
|
const $skipped_count = $("#skipped-count")
|
||||||
const $answered_count = $("#answered-count");
|
const $answered_count = $("#answered-count")
|
||||||
const $flagged_count = $("#flagged-count");
|
const $flagged_count = $("#flagged-count")
|
||||||
|
|
||||||
// Execution on Load
|
// Execution on Load
|
||||||
|
|
||||||
apply_settings(display_settings);
|
apply_settings(display_settings)
|
||||||
check_started();
|
check_started()
|
@ -1,21 +1,21 @@
|
|||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$("#od-font-test").click(function(){
|
$("#od-font-test").click(function(){
|
||||||
$("body").css("font-family", "opendyslexic3regular")
|
$("body").css("font-family", "opendyslexic3regular")
|
||||||
});
|
})
|
||||||
|
|
||||||
$('.test-code-input').keyup(function() {
|
$('.test-code-input').keyup(function() {
|
||||||
var input = $(this).val().split("-").join("").split("—").join("");
|
var input = $(this).val().split("-").join("").split("—").join("")
|
||||||
if (input.length > 0) {
|
if (input.length > 0) {
|
||||||
input = input.match(new RegExp('.{1,4}', 'g')).join("—");
|
input = input.match(new RegExp('.{1,4}', 'g')).join("—")
|
||||||
}
|
}
|
||||||
$(this).val(input);
|
$(this).val(input)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
$('form[name=form-quiz-start]').submit(function(event) {
|
$('form[name=form-quiz-start]').submit(function(event) {
|
||||||
|
|
||||||
var $form = $(this);
|
var $form = $(this)
|
||||||
var data = $form.serialize();
|
var data = $form.serialize()
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: window.location.pathname,
|
url: window.location.pathname,
|
||||||
@ -24,21 +24,21 @@ $('form[name=form-quiz-start]').submit(function(event) {
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
var id = response.id
|
var id = response.id
|
||||||
window.localStorage.setItem('id', id);
|
window.localStorage.setItem('id', id)
|
||||||
window.location.href = `/quiz/`;
|
window.location.href = `/quiz/`
|
||||||
},
|
},
|
||||||
error: function(response) {
|
error: function(response) {
|
||||||
error_response(response);
|
error_response(response)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
function error_response(response) {
|
function error_response(response) {
|
||||||
|
|
||||||
const $alert = $("#alert-box");
|
const $alert = $("#alert-box")
|
||||||
$alert.html('');
|
$alert.html('')
|
||||||
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||||
$alert.html(`
|
$alert.html(`
|
||||||
@ -47,18 +47,18 @@ function error_response(response) {
|
|||||||
${response.responseJSON.error}
|
${response.responseJSON.error}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`)
|
||||||
} else if (response.responseJSON.error instanceof Array) {
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
var output = ''
|
var output = ''
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
output += `
|
output += `
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
${response.responseJSON.error[i]}
|
${response.responseJSON.error[i]}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`
|
||||||
$alert.html(output);
|
$alert.html(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,13 +74,13 @@ $('#dismiss-cookie-alert').click(function(event){
|
|||||||
},
|
},
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
success: function(response){
|
success: function(response){
|
||||||
console.log(response);
|
console.log(response)
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
console.log(response);
|
console.log(response)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
|
|
||||||
})
|
})
|
@ -14,6 +14,9 @@
|
|||||||
<div class="container quiz-start-text">
|
<div class="container quiz-start-text">
|
||||||
You can use this panel to adjust the display settings for the exam. Please use the menu below to select the font face and font size. Below is a sample question so you can see how the exam will render with your chosen settings.
|
You can use this panel to adjust the display settings for the exam. Please use the menu below to select the font face and font size. Below is a sample question so you can see how the exam will render with your chosen settings.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="container quiz-start-text">
|
||||||
|
These settings will be stored locally on your browser window. No information about your preferences below will be collected by the app.
|
||||||
|
</div>
|
||||||
<div class="alert alert-primary quiz-start-text" role="alert">
|
<div class="alert alert-primary quiz-start-text" role="alert">
|
||||||
<strong>Note</strong>: Some fonts may not be available depending on your device and/or operating system.
|
<strong>Note</strong>: Some fonts may not be available depending on your device and/or operating system.
|
||||||
</div>
|
</div>
|
||||||
@ -123,7 +126,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?
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
/>
|
/>
|
||||||
{% block style %}
|
{% block style %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
<title>{% block title %} SKA Referee Test {% endblock %}</title>
|
||||||
{% include "quiz/components/og-meta.html" %}
|
{% include "quiz/components/og-meta.html" %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
@ -56,6 +56,8 @@
|
|||||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
></script>
|
></script>
|
||||||
|
<!-- jQuery UI -->
|
||||||
|
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
|
||||||
<!-- Custom js -->
|
<!-- Custom js -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var csrf_token = "{{ csrf_token() }}";
|
var csrf_token = "{{ csrf_token() }}";
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
||||||
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>
|
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>
|
@ -1,6 +1,6 @@
|
|||||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p>
|
<p class="navbar-brand mb-0 h1">SKA Refereeing Test</p>
|
||||||
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
|
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
|
||||||
<div class="d-flex justify-content align-middle">
|
<div class="d-flex justify-content align-middle">
|
||||||
<div class="container d-flex justify-content-center">
|
<div class="container d-flex justify-content-center">
|
||||||
|
@ -3,13 +3,36 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="instruction-container">
|
<div class="instruction-container">
|
||||||
<h3>Instructions</h3>
|
<h3>Instructions</h3>
|
||||||
|
<p>
|
||||||
|
Thank you for putting yourself forward to sit the SKA Referee Theory Exam. Please read the following instructions carefully.
|
||||||
|
</p>
|
||||||
|
<h4>
|
||||||
|
Taking the Exam
|
||||||
|
</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
The exam comprises 100 multiple-choice questions.
|
The exam consists of 100 questions, all of them multiple choice with two or three options, which are designed to test your knowledge of a wide range of rules. For each question, answer what decision you would give as a referee unless the question instructs otherwise.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
For each question, answer what decision you would give as a referee unless the question instructs otherwise.
|
It should take around an hour to complete.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
The exam should be taken under exam conditions. Materials such as the official rules, guidelines, revision resources, or similar should not be consulted during the test.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
We would remind candidates that whilst we are relying on your honesty in this test, your theory knowledge will make up a part of the practical assessment when you are observed refereeing a game.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
You also may not discuss the test with any other person while you are sitting it.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
If you have any queries before the exam or would like further feedback on the test, your emails are welcome.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<h4>
|
||||||
|
Using the Web App
|
||||||
|
</h4>
|
||||||
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
You will be able to customise the display settings of the exam from the settings panel by clicking on the red gear button <a class="btn btn-danger" aria-title="Settings" title="Settings" onclick="return false;"><i class="bi bi-gear-fill"></i></a>.
|
You will be able to customise the display settings of the exam from the settings panel by clicking on the red gear button <a class="btn btn-danger" aria-title="Settings" title="Settings" onclick="return false;"><i class="bi bi-gear-fill"></i></a>.
|
||||||
</li>
|
</li>
|
||||||
@ -17,7 +40,7 @@
|
|||||||
You can view your progress at a glance, as well as navigate to any question in the quiz, using the question grid, accessed via the yellow grid button <a class="btn btn-warning" aria-title="Question Grid" title="Question Grid" onclick="return false;"><i class="bi bi-table"></i></a>.
|
You can view your progress at a glance, as well as navigate to any question in the quiz, using the question grid, accessed via the yellow grid button <a class="btn btn-warning" aria-title="Question Grid" title="Question Grid" onclick="return false;"><i class="bi bi-table"></i></a>.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If you are unsure of the answer to a question or would like to revise a question, you can flag the question to review it later on using the flag button button <a class="btn btn-secondary" id="q-nav-flag" title="Flag Button." onclick="return false;"><i class="bi bi-flag-fill"></i></a>.
|
If you are unsure of the answer to a question or would like to return to a question later, you can flag the question using the flag button button <a class="btn btn-secondary" id="q-nav-flag" title="Flag Button." onclick="return false;"><i class="bi bi-flag-fill"></i></a> to serve as a reminder for you to come back to it later.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -46,7 +69,7 @@
|
|||||||
Results
|
Results
|
||||||
</h4>
|
</h4>
|
||||||
<p>
|
<p>
|
||||||
The results of your exam will be processed immediately and sent to the SKA Refereeing Coordinator. You will also be emailed a copy of your results.
|
The results of your exam will be processed immediately and sent to the SKA Refereeing Coordinator. You will also be emailed a copy of your results. If you do not receive an email, make sure to check your spam folder.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
When you are ready to begin the quiz, click the following button.
|
When you are ready to begin the quiz, click the following button.
|
||||||
|
@ -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%}
|
||||||
|
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
{% extends "quiz/components/base.html" %}
|
{% extends "quiz/components/base.html" %}
|
||||||
{% import "bootstrap/wtf.html" as wtf %}
|
{% import "bootstrap/wtf.html" as wtf %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-quiz-start" class="form-quiz-start">
|
<form name="form-quiz-start" class="form-quiz-start">
|
||||||
@ -44,3 +48,13 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
$( function() {
|
||||||
|
const clubs = {{ clubs|tojson }}
|
||||||
|
$('#club').autocomplete({
|
||||||
|
source: clubs
|
||||||
|
})
|
||||||
|
} )
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@ -1,10 +1,11 @@
|
|||||||
from ..forms.quiz import StartQuiz
|
from ..forms.quiz import StartQuiz
|
||||||
from ..models import Entry, Test
|
from ..models import Entry, Test
|
||||||
from ..tools.forms import send_errors_to_client
|
from ..tools.forms import send_errors_to_client
|
||||||
|
from ..tools.logs import write
|
||||||
from ..tools.test import redirect_if_started
|
from ..tools.test import redirect_if_started
|
||||||
|
|
||||||
from flask import abort, Blueprint, jsonify, redirect, render_template, request, session
|
from flask import Blueprint, jsonify, render_template, request, session
|
||||||
from flask.helpers import flash, url_for
|
from flask.helpers import abort, flash, redirect, url_for
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@ -28,6 +29,23 @@ def _instructions():
|
|||||||
|
|
||||||
@quiz.route('/start/', methods=['GET', 'POST'])
|
@quiz.route('/start/', methods=['GET', 'POST'])
|
||||||
def _start():
|
def _start():
|
||||||
|
clubs = [
|
||||||
|
'Dundee Korfball Club',
|
||||||
|
'Edinburgh City Korfball Club',
|
||||||
|
'Edinburgh Mavericks Korfball Club',
|
||||||
|
'Edinburgh University Korfball Club',
|
||||||
|
'Glasgow Korfball Club',
|
||||||
|
'Saint Andrews University Korfball Club',
|
||||||
|
'Strathclyde University Korfball Club'
|
||||||
|
]
|
||||||
|
try: entries = Entry.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
for entry in entries: clubs.append(entry.get_club())
|
||||||
|
clubs = list(set(clubs))
|
||||||
|
try: clubs.remove('')
|
||||||
|
except: pass
|
||||||
form = StartQuiz()
|
form = StartQuiz()
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
@ -37,11 +55,15 @@ def _start():
|
|||||||
entry.set_club(request.form.get('club'))
|
entry.set_club(request.form.get('club'))
|
||||||
entry.set_email(request.form.get('email'))
|
entry.set_email(request.form.get('email'))
|
||||||
code = request.form.get('test_code').replace('—', '').lower()
|
code = request.form.get('test_code').replace('—', '').lower()
|
||||||
test = Test.query.filter_by(code=code).first()
|
try: test = Test.query.filter_by(code=code).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400
|
||||||
entry.test = test
|
entry.test = test
|
||||||
|
entry.dataset = test.dataset
|
||||||
entry.user_code = request.form.get('user_code')
|
entry.user_code = request.form.get('user_code')
|
||||||
entry.user_code = None if entry.user_code == '' else entry.user_code.lower()
|
entry.user_code = None if entry.user_code == '' else entry.user_code.lower()
|
||||||
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400
|
|
||||||
if entry.user_code and entry.user_code not in test.adjustments: return jsonify({'error': f'The user code you entered is not valid.'}), 400
|
if entry.user_code and entry.user_code not in test.adjustments: return jsonify({'error': f'The user code you entered is not valid.'}), 400
|
||||||
if test.end_date < datetime.now(): return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y %H:%M")}.'}), 400
|
if test.end_date < datetime.now(): return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y %H:%M")}.'}), 400
|
||||||
if test.start_date > datetime.now(): return jsonify({'error': f'The exam has not yet opened. Your exam code will be valid from {test["start_date"].strftime("%d %b %Y %H:%M")}.'}), 400
|
if test.start_date > datetime.now(): return jsonify({'error': f'The exam has not yet opened. Your exam code will be valid from {test["start_date"].strftime("%d %b %Y %H:%M")}.'}), 400
|
||||||
@ -54,21 +76,28 @@ def _start():
|
|||||||
}), 200
|
}), 200
|
||||||
return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400
|
return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400
|
||||||
return send_errors_to_client(form=form)
|
return send_errors_to_client(form=form)
|
||||||
return render_template('/quiz/start_quiz.html', form = form)
|
return render_template('/quiz/start_quiz.html', form = form, clubs = clubs)
|
||||||
|
|
||||||
@quiz.route('/quiz/')
|
@quiz.route('/quiz/')
|
||||||
def _quiz():
|
def _quiz():
|
||||||
id = session.get('id')
|
id = session.get('id')
|
||||||
|
try:
|
||||||
if not id or not Entry.query.filter_by(id=id).first():
|
if not id or not Entry.query.filter_by(id=id).first():
|
||||||
flash('Your session was not recognised. Please sign in to the quiz again.', 'error')
|
flash('Your session was not recognised. Please sign in to the quiz again.', 'error')
|
||||||
session.pop('id', None)
|
session.pop('id', None)
|
||||||
return redirect(url_for('quiz._start'))
|
return redirect(url_for('quiz._start'))
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
return render_template('/quiz/client.html')
|
return render_template('/quiz/client.html')
|
||||||
|
|
||||||
@quiz.route('/result/')
|
@quiz.route('/result/')
|
||||||
def _result():
|
def _result():
|
||||||
id = session.get('id')
|
id = session.get('id')
|
||||||
entry = Entry.query.filter_by(id=id).first()
|
try: entry = Entry.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
if not entry: return abort(404)
|
if not entry: return abort(404)
|
||||||
session.pop('id',None)
|
session.pop('id',None)
|
||||||
score = round(100*entry.result['score']/entry.result['max'])
|
score = round(100*entry.result['score']/entry.result['max'])
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$("#od-font-test").click(function(){
|
$("#od-font-test").click(function(){
|
||||||
$("body").css("font-family", "opendyslexic3regular")
|
$("body").css("font-family", "opendyslexic3regular")
|
||||||
});
|
})
|
||||||
|
|
||||||
$('.test-code-input').keyup(function() {
|
$('.test-code-input').keyup(function() {
|
||||||
var input = $(this).val().split("-").join("").split("—").join("");
|
var input = $(this).val().split("-").join("").split("—").join("")
|
||||||
if (input.length > 0) {
|
if (input.length > 0) {
|
||||||
input = input.match(new RegExp('.{1,4}', 'g')).join("—");
|
input = input.match(new RegExp('.{1,4}', 'g')).join("—")
|
||||||
}
|
}
|
||||||
$(this).val(input);
|
$(this).val(input)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
$('form[name=form-quiz-start]').submit(function(event) {
|
$('form[name=form-quiz-start]').submit(function(event) {
|
||||||
|
|
||||||
var $form = $(this);
|
var $form = $(this)
|
||||||
var data = $form.serialize();
|
var data = $form.serialize()
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: window.location.pathname,
|
url: window.location.pathname,
|
||||||
@ -24,21 +24,21 @@ $('form[name=form-quiz-start]').submit(function(event) {
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
var id = response.id
|
var id = response.id
|
||||||
window.localStorage.setItem('id', id);
|
window.localStorage.setItem('id', id)
|
||||||
window.location.href = `/quiz/`;
|
window.location.href = `/quiz/`
|
||||||
},
|
},
|
||||||
error: function(response) {
|
error: function(response) {
|
||||||
error_response(response);
|
error_response(response)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
function error_response(response) {
|
function error_response(response) {
|
||||||
|
|
||||||
const $alert = $("#alert-box");
|
const $alert = $("#alert-box")
|
||||||
$alert.html('');
|
$alert.html('')
|
||||||
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||||
$alert.html(`
|
$alert.html(`
|
||||||
@ -47,18 +47,18 @@ function error_response(response) {
|
|||||||
${response.responseJSON.error}
|
${response.responseJSON.error}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`)
|
||||||
} else if (response.responseJSON.error instanceof Array) {
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
var output = ''
|
var output = ''
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
output += `
|
output += `
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
${response.responseJSON.error[i]}
|
${response.responseJSON.error[i]}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`
|
||||||
$alert.html(output);
|
$alert.html(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,13 +74,13 @@ $('#dismiss-cookie-alert').click(function(event){
|
|||||||
},
|
},
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
success: function(response){
|
success: function(response){
|
||||||
console.log(response);
|
console.log(response)
|
||||||
},
|
},
|
||||||
error: function(response){
|
error: function(response){
|
||||||
console.log(response);
|
console.log(response)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
|
|
||||||
})
|
})
|
@ -18,7 +18,7 @@
|
|||||||
<link rel="shortcut icon" href="{{ url_for('views.static', filename='favicon.ico') }}">
|
<link rel="shortcut icon" href="{{ url_for('views.static', filename='favicon.ico') }}">
|
||||||
{% block style %}
|
{% block style %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
<title>{% block title %} SKA Referee Test {% endblock %}</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
/>
|
/>
|
||||||
{% block style %}
|
{% block style %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
<title>{% block title %} SKA Referee Test {% endblock %}</title>
|
||||||
{% include "components/og-meta.html" %}
|
{% include "components/og-meta.html" %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
||||||
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>
|
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>
|
@ -1,6 +1,6 @@
|
|||||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p>
|
<p class="navbar-brand mb-0 h1">SKA Refereeing Test</p>
|
||||||
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
|
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
|
||||||
<div class="d-flex justify-content align-middle">
|
<div class="d-flex justify-content align-middle">
|
||||||
<div class="container d-flex justify-content-center">
|
<div class="container d-flex justify-content-center">
|
||||||
|
@ -3,9 +3,19 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
This web app stores data using cookies. The web site only stores the minimum information it needs to function.
|
This web app stores data using cookies. The web site only stores the minimum information it needs to function.
|
||||||
<h5>Site Administrators</h5>
|
</li>
|
||||||
|
<li>
|
||||||
|
All data stored on this app can be accessed by the SKA Committee and the maintainer of this app.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
This app is currently maintained by Vivek Santayana, a member of the Edinburgh City Korfball Club, with the permission of the SKA Committee.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h5>Site Administrators</h5>
|
||||||
<ul>
|
<ul>
|
||||||
<li>For site administrators, this web site uses encrypted cookies to store data from your log-in session.</li>
|
<li>For site administrators, this web site uses encrypted cookies to store data from your log-in session.</li>
|
||||||
<li>User information for administrators is encrypted and stored in a secure database, and are expunged when an account is deleted.</li>
|
<li>User information for administrators is encrypted and stored in a secure database, and are expunged when an account is deleted.</li>
|
||||||
@ -13,14 +23,14 @@
|
|||||||
|
|
||||||
<h5>Test Candidates</h5>
|
<h5>Test Candidates</h5>
|
||||||
<ul>
|
<ul>
|
||||||
<li>The web site will not be trackin your log in, and all information about your test attempt will be stored on your device until you submit it to the server.</li>
|
<li>The web site will not be tracking your log in, and all information about your test attempt will be stored on your device until you submit it to the server.</li>
|
||||||
<li>Data from your test, including identifying information such as your name and email address, will be recorded by the Scottish Korfball Association in order to oversee the training and qualification of referees.</li>
|
<li>Data from your test, including identifying information such as your name and email address, will be recorded by the Scottish Korfball Association in order to oversee the training and qualification of referees.</li>
|
||||||
<li>These records will be kept for three years or until the expiration of the theory exam qualification (whichever is later), and will be expunged securely thereafter.</li>
|
<li>These records will be kept for three years or until the expiration of the theory exam qualification (whichever is later), and will be expunged securely thereafter.</li>
|
||||||
<li>All identifying information about candidates will be encrypted and stored in a secure database.</li>
|
<li>All identifying information about candidates will be encrypted and stored in a secure database administered by the maintainer of this app.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h5>Requests to Delete Data</h5>
|
<h5>Requests to Delete Data</h5>
|
||||||
<ul>
|
<ul>
|
||||||
<li>You can request to have any of your data that is held here deleted by emailing <a href="mailto:refereeing@scotlandkorfball.co.uk">refereeing@scotlandkorfball.co.uk</a>.</li>
|
<li>You can request to view or delete data that the app stores about you by emailing <a href="mailto:refereeing@scotlandkorfball.co.uk">refereeing@scotlandkorfball.co.uk</a>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -1,8 +1,7 @@
|
|||||||
from .data import load
|
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
from ..tools.logs import write
|
||||||
|
|
||||||
from flask import abort, redirect
|
from flask.helpers import abort, flash, redirect, url_for
|
||||||
from flask.helpers import flash, url_for
|
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@ -10,9 +9,13 @@ from functools import wraps
|
|||||||
def require_account_creation(function):
|
def require_account_creation(function):
|
||||||
@wraps(function)
|
@wraps(function)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
if User.query.count() == 0:
|
if User.query.count() == 0:
|
||||||
flash('Please register a user account.', 'alert')
|
flash('Please register a user account.', 'alert')
|
||||||
return redirect(url_for('admin._register'))
|
return redirect(url_for('admin._register'))
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when checking for existing accounts: {exception}')
|
||||||
|
return abort(500)
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
|
from ..models import Dataset, Test
|
||||||
|
from ..tools.logs import write
|
||||||
|
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
|
from flask.helpers import abort, flash, redirect, url_for
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from random import shuffle
|
from random import shuffle
|
||||||
|
from statistics import mean, median, stdev
|
||||||
|
from typing import Union
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
def load(filename:str):
|
def load(filename:str):
|
||||||
data_dir = Path(app.config.get('DATA'))
|
data_dir = Path(app.config.get('DATA'))
|
||||||
@ -67,3 +74,95 @@ def get_tag_list(dataset:list):
|
|||||||
if block['type'] == 'block':
|
if block['type'] == 'block':
|
||||||
for question in block['questions']: output = list(set(output) | set(question['tags']))
|
for question in block['questions']: output = list(set(output) | set(question['tags']))
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def check_dataset_exists(function):
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try: datasets = Dataset.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when checking existing datasets: {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not datasets:
|
||||||
|
flash('There are no available question datasets. Please upload a question dataset first, or use the question editor to create a new dataset.', 'error')
|
||||||
|
return redirect(url_for('admin._questions'))
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def check_test_exists(function):
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try: tests = Test.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when checking existing datasets: {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not tests:
|
||||||
|
flash('There are no exams configured. Please create an exam first.', 'error')
|
||||||
|
return redirect(url_for('admin._tests'))
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def analyse(subject:Union[Dataset,Test]) -> dict:
|
||||||
|
output = {
|
||||||
|
'answers': {},
|
||||||
|
'entries': 0,
|
||||||
|
'grades': {
|
||||||
|
'merit': 0,
|
||||||
|
'pass': 0,
|
||||||
|
'fail': 0
|
||||||
|
},
|
||||||
|
'scores': {
|
||||||
|
'mean': 0,
|
||||||
|
'median': 0,
|
||||||
|
'stdev': 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scores_raw = []
|
||||||
|
if isinstance(subject, Test):
|
||||||
|
for entry in subject.entries:
|
||||||
|
if entry.answers:
|
||||||
|
for question, answer in entry.answers.items():
|
||||||
|
if int(question) not in output['answers']: output['answers'][int(question)] = {}
|
||||||
|
if int(answer) not in output['answers'][int(question)]: output['answers'][int(question)][int(answer)] = 0
|
||||||
|
output['answers'][int(question)][int(answer)] += 1
|
||||||
|
if entry.result:
|
||||||
|
output['entries'] += 1
|
||||||
|
output['grades'][entry.result['grade']] += 1
|
||||||
|
scores_raw.append(int(entry.result['score']))
|
||||||
|
else:
|
||||||
|
for test in subject.tests:
|
||||||
|
for entry in test.entries:
|
||||||
|
if entry.answers:
|
||||||
|
for question, answer in entry.answers.items():
|
||||||
|
if int(question) not in output['answers']: output['answers'][int(question)] = {}
|
||||||
|
if int(answer) not in output['answers'][int(question)]: output['answers'][int(question)][int(answer)] = 0
|
||||||
|
output['answers'][int(question)][int(answer)] += 1
|
||||||
|
if entry.result:
|
||||||
|
output['entries'] += 1
|
||||||
|
output['grades'][entry.result['grade']] += 1
|
||||||
|
scores_raw.append(entry.result['score'])
|
||||||
|
output['scores']['mean'] = mean(scores_raw)
|
||||||
|
output['scores']['median'] = median(scores_raw)
|
||||||
|
output['scores']['stdev'] = stdev(scores_raw, output['scores']['mean']) if len(scores_raw) > 1 else None
|
||||||
|
return output
|
||||||
|
|
||||||
|
def parse_questions(dataset:list):
|
||||||
|
output = []
|
||||||
|
for block in dataset:
|
||||||
|
if block['type'] == 'question':
|
||||||
|
question = {
|
||||||
|
'q_no': block['q_no'],
|
||||||
|
'tags': block['tags'],
|
||||||
|
'correct': block['correct']
|
||||||
|
}
|
||||||
|
question['options'] = [*enumerate(block['options'])]
|
||||||
|
output.append(question)
|
||||||
|
elif block['type'] == 'block':
|
||||||
|
for _question in block['questions']:
|
||||||
|
question = {
|
||||||
|
'q_no': _question['q_no'],
|
||||||
|
'tags': _question['tags'],
|
||||||
|
'correct': _question['correct']
|
||||||
|
}
|
||||||
|
question['options'] = [*enumerate(_question['options'])]
|
||||||
|
output.append(question)
|
||||||
|
return output
|
@ -1,30 +1,8 @@
|
|||||||
|
from ..tools.logs import write
|
||||||
from ..extensions import db
|
|
||||||
|
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from wtforms.validators import ValidationError
|
from wtforms.validators import ValidationError
|
||||||
|
|
||||||
import json
|
|
||||||
from sqlalchemy.ext import mutable
|
|
||||||
|
|
||||||
class JsonEncodedDict(db.TypeDecorator):
|
|
||||||
"""Enables JSON storage by encoding and decoding on the fly."""
|
|
||||||
impl = db.Text
|
|
||||||
|
|
||||||
def process_bind_param(self, value, dialect):
|
|
||||||
if value is None:
|
|
||||||
return '{}'
|
|
||||||
else:
|
|
||||||
return json.dumps(value)
|
|
||||||
|
|
||||||
def process_result_value(self, value, dialect):
|
|
||||||
if value is None:
|
|
||||||
return {}
|
|
||||||
else:
|
|
||||||
return json.loads(value)
|
|
||||||
|
|
||||||
mutable.MutableDict.associate_with(JsonEncodedDict)
|
|
||||||
|
|
||||||
def value(min:int=0, max:int=None):
|
def value(min:int=0, max:int=None):
|
||||||
if not max:
|
if not max:
|
||||||
message = f'Value must be greater than {min}.'
|
message = f'Value must be greater than {min}.'
|
||||||
@ -47,7 +25,10 @@ def get_time_options():
|
|||||||
|
|
||||||
def get_dataset_choices():
|
def get_dataset_choices():
|
||||||
from ..models import Dataset
|
from ..models import Dataset
|
||||||
datasets = Dataset.query.all()
|
try: datasets = Dataset.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when fetching dataset lists: {exception}')
|
||||||
|
return []
|
||||||
dataset_choices = []
|
dataset_choices = []
|
||||||
for dataset in datasets:
|
for dataset in datasets:
|
||||||
label = dataset.get_name()
|
label = dataset.get_name()
|
||||||
@ -57,5 +38,5 @@ def get_dataset_choices():
|
|||||||
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
|
@ -1,17 +1,19 @@
|
|||||||
from .data import randomise_list
|
from .data import randomise_list
|
||||||
from ..models import Entry
|
from ..models import Entry
|
||||||
|
from ..tools.logs import write
|
||||||
|
|
||||||
from flask import redirect, request, session
|
from flask import request, session
|
||||||
from flask.helpers import url_for
|
from flask.helpers import abort, redirect, url_for
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
def parse_test_code(code):
|
def parse_test_code(code):
|
||||||
return code.replace('—', '').lower()
|
return code.replace('—', '').lower()
|
||||||
|
|
||||||
def generate_questions(dataset:list):
|
def generate_questions(dataset:list, randomise:bool=True):
|
||||||
output = []
|
output = []
|
||||||
for block in randomise_list(dataset):
|
question_dataset = randomise_list(dataset) if randomise else dataset
|
||||||
|
for block in question_dataset:
|
||||||
if block['type'] == 'question':
|
if block['type'] == 'question':
|
||||||
question = {
|
question = {
|
||||||
'type': 'question',
|
'type': 'question',
|
||||||
@ -19,11 +21,12 @@ def generate_questions(dataset:list):
|
|||||||
'question_header': '',
|
'question_header': '',
|
||||||
'text': block['text']
|
'text': block['text']
|
||||||
}
|
}
|
||||||
if block['q_type'] == 'Multiple Choice': question['options'] = randomise_list([*enumerate(block['options'])])
|
if block['q_type'] == 'Multiple Choice' and randomise: question['options'] = randomise_list([*enumerate(block['options'])])
|
||||||
else: question['options'] = [*enumerate(block['options'])]
|
else: question['options'] = [*enumerate(block['options'])]
|
||||||
output.append(question)
|
output.append(question)
|
||||||
elif block['type'] == 'block':
|
elif block['type'] == 'block':
|
||||||
for key, _question in enumerate(randomise_list(block['questions'])):
|
block_questions = randomise_list(block['questions']) if randomise else block['questions']
|
||||||
|
for key, _question in enumerate(block_questions):
|
||||||
question = {
|
question = {
|
||||||
'type': 'block',
|
'type': 'block',
|
||||||
'q_no': _question['q_no'],
|
'q_no': _question['q_no'],
|
||||||
@ -32,7 +35,7 @@ def generate_questions(dataset:list):
|
|||||||
'block_q_no': key,
|
'block_q_no': key,
|
||||||
'text': _question['text']
|
'text': _question['text']
|
||||||
}
|
}
|
||||||
if _question['q_type'] == 'Multiple Choice': question['options'] = randomise_list([*enumerate(_question['options'])])
|
if _question['q_type'] == 'Multiple Choice' and randomise: question['options'] = randomise_list([*enumerate(_question['options'])])
|
||||||
else: question['options'] = [*enumerate(_question['options'])]
|
else: question['options'] = [*enumerate(_question['options'])]
|
||||||
output.append(question)
|
output.append(question)
|
||||||
return output
|
return output
|
||||||
@ -129,8 +132,11 @@ def redirect_if_started(function):
|
|||||||
@wraps(function)
|
@wraps(function)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
id = session.get('id')
|
id = session.get('id')
|
||||||
if request.method == 'GET' and id and Entry.query.filter_by(id=id).first():
|
try:
|
||||||
return redirect(url_for('quiz._quiz'))
|
if request.method == 'GET' and id and Entry.query.filter_by(id=id).first(): return redirect(url_for('quiz._quiz'))
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when checking if test has been started: {exception}')
|
||||||
|
return abort(500)
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
0
ref-test/app/view/__init__.py
Normal file
0
ref-test/app/view/__init__.py
Normal file
260
ref-test/app/view/static/css/style.css
Normal file
260
ref-test/app/view/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
body {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
background-color: lightgray;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
-ms-flex-align: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-display {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-heading {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input,
|
||||||
|
.form-label-group label {
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0; /* Override default `<label>` margin */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #495057;
|
||||||
|
cursor: text; /* Match the input under the label */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: all .1s ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0%;
|
||||||
|
border-bottom: 2px solid #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:active, .form-label-group input:focus {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-webkit-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-moz-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:not(:placeholder-shown) {
|
||||||
|
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||||
|
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||||
|
padding-top: calc(var(--input-padding-y) / 3);
|
||||||
|
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
transform: scale(1.5);
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-forgot-password {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button button, .form-submission-button a {
|
||||||
|
margin: 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTables_wrapper .dt-buttons {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
float:none;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button, .row-actions a {
|
||||||
|
margin: 0px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cookie-alert {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dismiss-cookie-alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-db-empty {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: 14pt;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input, .form-select-input {
|
||||||
|
position: relative;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input input,
|
||||||
|
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
font-size: 16pt;
|
||||||
|
width: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker::-webkit-calendar-picker-indicator {
|
||||||
|
border: 1px;
|
||||||
|
border-color: gray;
|
||||||
|
border-radius: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input label, .form-select-input label {
|
||||||
|
/* position: absolute; */
|
||||||
|
/* top: 0;
|
||||||
|
left: 0; */
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0; /* Override default `<label>` margin */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #495057;
|
||||||
|
cursor: text; /* Match the input under the label */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: all .1s ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-upload {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-action-buttons, .test-action {
|
||||||
|
margin: 5px auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Change Autocomplete styles in Chrome*/
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
textarea:-webkit-autofill,
|
||||||
|
textarea:-webkit-autofill:hover,
|
||||||
|
textarea:-webkit-autofill:focus,
|
||||||
|
select:-webkit-autofill,
|
||||||
|
select:-webkit-autofill:hover,
|
||||||
|
select:-webkit-autofill:focus {
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for Edge
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@supports (-ms-ime-align: auto) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for IE
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
30
ref-test/app/view/static/css/view.css
Normal file
30
ref-test/app/view/static/css/view.css
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
.info-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
width:fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alert-box {
|
||||||
|
margin: 30px auto;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
border: 2px solid black;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-body, .question-block {
|
||||||
|
padding: 0px 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding: 0px 2em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
2
ref-test/app/view/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/app/view/static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
115
ref-test/app/view/static/js/script.js
Normal file
115
ref-test/app/view/static/js/script.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
// Menu Highlight Scripts
|
||||||
|
const menuItems = document.getElementsByClassName('nav-link')
|
||||||
|
for(let i = 0; i < menuItems.length; i++) {
|
||||||
|
if(menuItems[i].pathname == window.location.pathname) {
|
||||||
|
menuItems[i].classList.add('active')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dropdownItems = document.getElementsByClassName('dropdown-item')
|
||||||
|
for(let i = 0; i< dropdownItems.length; i++) {
|
||||||
|
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||||
|
dropdownItems[i].classList.add('active')
|
||||||
|
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General Post Method Form Processing Script
|
||||||
|
$('form.form-post').submit(function(event) {
|
||||||
|
|
||||||
|
var $form = $(this)
|
||||||
|
var data = $form.serialize()
|
||||||
|
var url = $(this).prop('action')
|
||||||
|
var rel_success = $(this).data('rel-success')
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response) {
|
||||||
|
if (response.redirect_to) {
|
||||||
|
window.location.href = response.redirect_to
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
window.location.href = rel_success
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
function error_response(response) {
|
||||||
|
|
||||||
|
const $alert = $("#alert-box")
|
||||||
|
$alert.html('')
|
||||||
|
|
||||||
|
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||||
|
$alert.html(`
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
|
var output = ''
|
||||||
|
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
|
output += `
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error[i]}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
$alert.html(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$alert.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss Cookie Alert
|
||||||
|
$('#dismiss-cookie-alert').click(function(event){
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/cookies/',
|
||||||
|
type: 'POST',
|
||||||
|
data: {
|
||||||
|
time: Date.now()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
console.log(response)
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
console.log(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create New Dataset
|
||||||
|
$('.create-new-dataset').click(function(event){
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/editor/new/',
|
||||||
|
type: 'POST',
|
||||||
|
data: {
|
||||||
|
time: Date.now()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
if (response.redirect_to) {
|
||||||
|
window.location.href = response.redirect_to
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
console.log(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
151
ref-test/app/view/static/js/view.js
Normal file
151
ref-test/app/view/static/js/view.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
// Variable Declarations
|
||||||
|
const $control_panel = $('.control-panel')
|
||||||
|
const $info_panel = $('.info-panel')
|
||||||
|
const $viewer_panel = $('.viewer-panel')
|
||||||
|
|
||||||
|
var element_index = 0
|
||||||
|
|
||||||
|
// Info Button Listener
|
||||||
|
$control_panel.find('button').click(function(event){
|
||||||
|
if ($info_panel.is(":hidden")) {
|
||||||
|
$viewer_panel.hide()
|
||||||
|
$info_panel.fadeIn()
|
||||||
|
$(this).addClass('active')
|
||||||
|
} else {
|
||||||
|
$info_panel.hide()
|
||||||
|
$viewer_panel.fadeIn()
|
||||||
|
$(this).removeClass('active')
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
function parse_data(data) {
|
||||||
|
var block
|
||||||
|
var obj
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
block = data[i]
|
||||||
|
obj = document.createElement('div')
|
||||||
|
obj.classList = 'block'
|
||||||
|
if (block['type'] == 'question') {
|
||||||
|
text = document.createElement('p')
|
||||||
|
text.innerHTML = `<strong>Question ${block['q_no'] + 1}.</strong> ${block['text']}`
|
||||||
|
obj.append(text)
|
||||||
|
question_body = document.createElement('div')
|
||||||
|
question_body.className ='question-body'
|
||||||
|
type = document.createElement('p')
|
||||||
|
type.innerHTML = `<strong>Question Type:</strong> ${block['q_type']}`
|
||||||
|
question_body.append(type)
|
||||||
|
options = document.createElement('p')
|
||||||
|
options.innerHTML = '<strong>Options:</strong>'
|
||||||
|
option_list = document.createElement('ul')
|
||||||
|
for (let _i = 0; _i < block['options'].length; _i++) {
|
||||||
|
option = document.createElement('li')
|
||||||
|
option.innerHTML = block['options'][_i]
|
||||||
|
if (block['correct'] == _i) {
|
||||||
|
option.innerHTML += ' <span class="badge rounded-pill bg-success">Correct</span>'
|
||||||
|
}
|
||||||
|
option_list.append(option)
|
||||||
|
}
|
||||||
|
options.append(option_list)
|
||||||
|
question_body.append(options)
|
||||||
|
tags = document.createElement('p')
|
||||||
|
tags.innerHTML = `<strong>Tags:</strong>`
|
||||||
|
tag_list = document.createElement('ul')
|
||||||
|
for (let _i = 0; _i < block['tags'].length; _i++) {
|
||||||
|
tag = document.createElement('li')
|
||||||
|
tag.innerHTML = block['tags'][_i]
|
||||||
|
tag_list.append(tag)
|
||||||
|
}
|
||||||
|
tags.append(tag_list)
|
||||||
|
question_body.append(tags)
|
||||||
|
obj.append(question_body)
|
||||||
|
} else if (block['type'] == 'block') {
|
||||||
|
meta = document.createElement('p')
|
||||||
|
meta.innerHTML = `<strong>Block ${i+1}.</strong> ${block['questions'].length} questions.`
|
||||||
|
obj.append(meta)
|
||||||
|
header = document.createElement('blockquote')
|
||||||
|
header.innerText = block['question_header']
|
||||||
|
obj.append(header)
|
||||||
|
var block_question = document.createElement('div')
|
||||||
|
var question
|
||||||
|
block_question.className = 'question-block'
|
||||||
|
for (let _i = 0; _i < block['questions'].length; _i++) {
|
||||||
|
question = block['questions'][_i]
|
||||||
|
text = document.createElement('p')
|
||||||
|
text.innerHTML = `<strong>Question ${question['q_no'] + 1}.</strong> ${question['text']}`
|
||||||
|
block_question.append(text)
|
||||||
|
question_body = document.createElement('div')
|
||||||
|
question_body.className ='question-body'
|
||||||
|
type = document.createElement('p')
|
||||||
|
type.innerHTML = `<strong>Question Type:</strong> ${question['q_type']}`
|
||||||
|
question_body.append(type)
|
||||||
|
options = document.createElement('p')
|
||||||
|
options.innerHTML = '<strong>Options:</strong>'
|
||||||
|
option_list = document.createElement('ul')
|
||||||
|
for (let __i = 0; __i < question['options'].length; __i++) {
|
||||||
|
option = document.createElement('li')
|
||||||
|
option.innerHTML = question['options'][__i]
|
||||||
|
if (question['correct'] == __i) {
|
||||||
|
option.innerHTML += ' <span class="badge rounded-pill bg-success">Correct</span>'
|
||||||
|
}
|
||||||
|
option_list.append(option)
|
||||||
|
}
|
||||||
|
options.append(option_list)
|
||||||
|
question_body.append(options)
|
||||||
|
tags = document.createElement('p')
|
||||||
|
tags.innerHTML = `<strong>Tags:</strong>`
|
||||||
|
tag_list = document.createElement('ul')
|
||||||
|
for (let __i = 0; __i < question['tags'].length; __i++) {
|
||||||
|
tag = document.createElement('li')
|
||||||
|
tag.innerHTML = question['tags'][__i]
|
||||||
|
tag_list.append(tag)
|
||||||
|
}
|
||||||
|
tags.append(tag_list)
|
||||||
|
question_body.append(tags)
|
||||||
|
block_question.append(question_body)
|
||||||
|
obj.append(block_question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$viewer_panel.append(obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyse Button
|
||||||
|
$('.dataset-analyse').click(function(event) {
|
||||||
|
|
||||||
|
let id = $(this).data('id')
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/analysis/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id, 'class': 'dataset'}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = response
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
84
ref-test/app/view/templates/view/components/base.html
Normal file
84
ref-test/app/view/templates/view/components/base.html
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/view.css') }}"
|
||||||
|
/>
|
||||||
|
{% block style %}
|
||||||
|
{% endblock %}
|
||||||
|
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||||
|
{% include "view/components/og-meta.html" %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
{% block navbar %}
|
||||||
|
{% include "view/components/navbar.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% include "view/components/server-alerts.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="container site-footer mt-5">
|
||||||
|
{% block footer %}
|
||||||
|
{% include "view/components/footer.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||||
|
<script
|
||||||
|
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||||
|
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||||
|
crossorigin="anonymous">
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||||
|
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||||
|
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
<!-- Custom js -->
|
||||||
|
<script type="text/javascript">
|
||||||
|
var csrf_token = "{{ csrf_token() }}";
|
||||||
|
|
||||||
|
$.ajaxSetup({
|
||||||
|
beforeSend: function(xhr, settings) {
|
||||||
|
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||||
|
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||||
|
></script>
|
||||||
|
{% block script %}
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1 @@
|
|||||||
|
<div id="alert-box" tabindex="-1"></div>
|
28
ref-test/app/view/templates/view/components/datatable.html
Normal file
28
ref-test/app/view/templates/view/components/datatable.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{% extends "view/components/base.html" %}
|
||||||
|
{% block datatable_css %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
|
||||||
|
{% endblock %}
|
||||||
|
{% block datatable_scripts %}
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||||
|
{% endblock %}
|
2
ref-test/app/view/templates/view/components/footer.html
Normal file
2
ref-test/app/view/templates/view/components/footer.html
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||||
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
|||||||
|
{% extends "view/components/base.html" %}
|
||||||
|
{% import "bootstrap/wtf.html" as wtf %}
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% endblock %}
|
137
ref-test/app/view/templates/view/components/navbar.html
Normal file
137
ref-test/app/view/templates/view/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbar"
|
||||||
|
aria-controls="navbar"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle Navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if not current_user.is_authenticated %}
|
||||||
|
<li class="nav-item" id="nav-login">
|
||||||
|
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item dropdown" id="nav-results">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-results"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._view_entries') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-results"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-tests">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-tests"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._tests') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Exams
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-tests"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-settings">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-settings"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._settings') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-settings"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-account">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-account"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-account"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
18
ref-test/app/view/templates/view/components/og-meta.html
Normal file
18
ref-test/app/view/templates/view/components/og-meta.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||||
|
<meta property="og:locale" content="en_UK" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||||
|
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
|
||||||
|
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||||
|
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||||
|
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||||
|
<meta property="og:image:width" content="512" />
|
||||||
|
<meta property="og:image:height" content="512" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||||
|
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||||
|
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||||
|
<meta name="twitter:creator" content="@viveksantayana" />
|
||||||
|
<meta name="twitter:site" content="@viveksantayana" />
|
||||||
|
<meta name="theme-color" content="#343a40" />
|
||||||
|
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">
|
@ -0,0 +1,23 @@
|
|||||||
|
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,43 @@
|
|||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% set cookie_flash_flag = namespace(value=False) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
{% if category == "error" %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "success" %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "warning" %}
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "cookie_alert" %}
|
||||||
|
{% if not cookie_flash_flag.value %}
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<div class="d-flex justify-content-center w-100">
|
||||||
|
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set cookie_flash_flag.value = True %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
127
ref-test/app/view/templates/view/console.html
Normal file
127
ref-test/app/view/templates/view/console.html
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
{% extends "view/components/base.html" %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/view.css') }}"
|
||||||
|
/>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>View Questions</h1>
|
||||||
|
<div class="container">
|
||||||
|
<p class="lead">
|
||||||
|
This page lists all the questions in the selected dataset.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="container control-panel">
|
||||||
|
<button class="btn btn-primary" aria-title="Information" title="Information"><i class="bi bi-info-circle-fill"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="container info-panel">
|
||||||
|
<h3>
|
||||||
|
Information
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Questions in the test are arranged in blocks. Blocks can be of two types: <strong>Blocks</strong> of multiple related questions, and <strong>Single Questions</strong> that are not part of a block.
|
||||||
|
You can add, remove, or edit both Blockss and Questions through this editor.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Blocks</strong> are useful when you have a section of the test that contains multiple questions that are related to each other, for example if there is a scenario-based section where a series of questions are about the same situation.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Blocks can contain any number of questions within them, but cannot contain nested blocks.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When you set up a block, you can also add <strong>header text</strong> that will be displayed with each question.
|
||||||
|
You can use this to provide common information for a scenario across a series of questions.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Questions come in three types:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Yes/No</strong> for when there is only a yes or no option,
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Multiple Choice</strong> for your regular multiple choice questions, and
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Ordered List</strong> for multiple choice questions that will be displayed in the same order as listed here.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Normally, multiple choice questions will have the order of the options randomised.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Questions will be displayed to candidates in a randomised order.
|
||||||
|
Blocks of questions will be kept together, but the order within the block will also be randomised.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Questions can also be categorised using <strong>tags</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="lead">
|
||||||
|
Placeholder for Questions Remaining in a Block
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In order to show how many questions are remaining inside a block, e.g. to say ‘the next n questions are about a specific scenario’, the app uses the placeholder <code><block_remaining_questions></code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="container viewer-panel">
|
||||||
|
<h3>
|
||||||
|
Question Dataset
|
||||||
|
</h3>
|
||||||
|
<div class="container dataset-metadata">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Dataset Name</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ dataset.get_name() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Author</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ dataset.creator.get_username() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Last Updated</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if dataset.default %}
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<input type="checkbox" aria-label="Default" class="dataset-default" checked disabled>
|
||||||
|
</span>
|
||||||
|
<span class="form-control">
|
||||||
|
Default Dataset
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-success dataset-analyse {% if not dataset.entries %} disabled {% endif %}"
|
||||||
|
data-id="{{dataset.id}}"
|
||||||
|
title="Analyse Answers"
|
||||||
|
data-action="analyse"
|
||||||
|
>
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
Analyse Answers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
const target = "{{ url_for('api._editor') }}"
|
||||||
|
const id = "{{ dataset.id }}"
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/view.js') }}"
|
||||||
|
></script>
|
||||||
|
{% endblock %}
|
27
ref-test/app/view/templates/view/index.html
Normal file
27
ref-test/app/view/templates/view/index.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends "view/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-editor" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for(request.endpoint, **request.view_args) }}">
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
<h2 class="form">View Questions</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-select-input">
|
||||||
|
{{ form.dataset(placeholder="Select Question Dataset") }}
|
||||||
|
{{ form.dataset.label }}
|
||||||
|
</div>
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button class="btn btn-md btn-success btn-block" type="submit">
|
||||||
|
<i class="bi bi-book-fill button-icon"></i>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
46
ref-test/app/view/views.py
Normal file
46
ref-test/app/view/views.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from ..forms.admin import EditDataset
|
||||||
|
from ..models import Dataset, User
|
||||||
|
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
||||||
|
from ..tools.data import check_dataset_exists
|
||||||
|
from ..tools.logs import write
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, render_template, request
|
||||||
|
from flask.helpers import abort, flash, redirect, url_for
|
||||||
|
from flask_login import login_required
|
||||||
|
|
||||||
|
view = Blueprint(
|
||||||
|
name='view',
|
||||||
|
import_name=__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static'
|
||||||
|
)
|
||||||
|
|
||||||
|
@view.route('/', methods=['GET','POST'])
|
||||||
|
@login_required
|
||||||
|
@check_dataset_exists
|
||||||
|
def _view():
|
||||||
|
form = EditDataset()
|
||||||
|
form.dataset.choices = get_dataset_choices()
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
id = request.form.get('dataset')
|
||||||
|
return jsonify({'success': 'Selected dataset', 'redirect_to': url_for('view._view_console', id=id)}),200
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
form.process()
|
||||||
|
return render_template('/view/index.html', form=form)
|
||||||
|
|
||||||
|
@view.route('/<string:id>/')
|
||||||
|
@login_required
|
||||||
|
@check_dataset_exists
|
||||||
|
def _view_console(id:str=None):
|
||||||
|
try:
|
||||||
|
dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
datasets = Dataset.query.count()
|
||||||
|
users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not dataset:
|
||||||
|
flash('Invalid dataset ID.', 'error')
|
||||||
|
return redirect(url_for('admin._questions'))
|
||||||
|
return render_template('/view/console.html', dataset=dataset)
|
1
ref-test/data/.gitignore
vendored
1
ref-test/data/.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
*
|
|
35
ref-test/install.py
Executable file
35
ref-test/install.py
Executable file
@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from main import app
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import *
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
"""Create File for SQLite Database"""
|
||||||
|
if database_uri[0:6].lower() == 'sqlite':
|
||||||
|
if not database_exists(database_uri):
|
||||||
|
create_database(database_uri)
|
||||||
|
write('system.log', 'No SQLite file found. Creating a new database.')
|
@ -1,27 +1,33 @@
|
|||||||
blinker==1.4
|
blinker==1.5
|
||||||
cffi==1.15.0
|
cffi==1.15.1
|
||||||
click==8.1.3
|
click==8.1.3
|
||||||
cryptography==37.0.2
|
cryptography==39.0.2
|
||||||
dnspython==2.2.1
|
dnspython==2.3.0
|
||||||
dominate==2.6.0
|
dominate==2.7.0
|
||||||
email-validator==1.2.1
|
email-validator==1.3.1
|
||||||
Flask==2.1.2
|
Flask==2.2.3
|
||||||
Flask-Bootstrap==3.3.7.1
|
Flask-Bootstrap==3.3.7.1
|
||||||
Flask-Login==0.6.1
|
Flask-Login==0.6.2
|
||||||
Flask-Mail==0.9.1
|
Flask-Mail==0.9.1
|
||||||
Flask-SQLAlchemy==2.5.1
|
Flask-SQLAlchemy==3.0.3
|
||||||
Flask-WTF==1.0.1
|
Flask-WTF==1.1.1
|
||||||
greenlet==1.1.2
|
greenlet==2.0.2
|
||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
idna==3.3
|
idna==3.4
|
||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
MarkupSafe==2.1.1
|
MarkupSafe==2.1.2
|
||||||
|
pip==23.0.1
|
||||||
pycparser==2.21
|
pycparser==2.21
|
||||||
python-dotenv==0.20.0
|
PyMySQL==1.0.2
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
setuptools==67.4.0
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
SQLAlchemy==1.4.37
|
SQLAlchemy==2.0.4
|
||||||
SQLAlchemy-Utils==0.38.2
|
sqlalchemy-json==0.5.0
|
||||||
|
SQLAlchemy-Utils==0.40.0
|
||||||
|
typing_extensions==4.5.0
|
||||||
visitor==0.1.3
|
visitor==0.1.3
|
||||||
Werkzeug==2.1.2
|
Werkzeug==2.2.3
|
||||||
|
wheel==0.38.4
|
||||||
WTForms==3.0.1
|
WTForms==3.0.1
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user