Compare commits
145 Commits
d9f967811f
...
ec52ebffa5
Author | SHA1 | Date | |
---|---|---|---|
ec52ebffa5 | |||
7d287874cd | |||
78e4afdc71 | |||
11e740ea44 | |||
cc995925bb | |||
757425494f | |||
6d887f1bfd | |||
877b191a38 | |||
30175355a6 | |||
27a58acc73 | |||
ae31b2592e | |||
94c61f8d8a | |||
3781e80fc5 | |||
1873772167 | |||
957e6f02d6 | |||
183aeac9ee | |||
d5559c499d | |||
e1a90d5f64 | |||
72cd18b76f | |||
7b2a2ce90c | |||
091fdbe891 | |||
cc45bf7acd | |||
eeaf676ee6 | |||
af9801ac24 | |||
df54ca7ff3 | |||
536e1fe426 | |||
4c5aa66d8e | |||
96cca77b2f | |||
9ea336b5c2 | |||
673ccbcb9c | |||
a5dedc145c | |||
21641ce21f | |||
abe515b586 | |||
7874d3f99f | |||
8ccd34611e | |||
d0232557bc | |||
663b976b3b | |||
20f580d6a6 | |||
2b6b5d8f73 | |||
198e2cecb0 | |||
8df4583ee0 | |||
2641b3e060 | |||
843ed247b6 | |||
e7077cd193 | |||
d4f59769c6 | |||
fbe19e43f7 | |||
9a5e24d362 | |||
372333cd84 | |||
352c89d69f | |||
|
ca1a6efd57 | ||
|
8675e78082 | ||
cc466f4a20 | |||
37ab26e72a | |||
|
039b58709e | ||
|
3dc7b1f74b | ||
eb9ca82cb3 | |||
cd7005d713 | |||
|
d25dc5ed45 | ||
|
9d0ae15f74 | ||
50cff0245f | |||
02e4e0dc1c | |||
|
7f7a783c8a | ||
|
88836296b8 | ||
207f748c93 | |||
8503dc230d | |||
2c0dcd8661 | |||
ec1de247c9 | |||
05ec62994e | |||
76d9031cb0 | |||
e69c79df52 | |||
8a4ae4cb91 | |||
ea4edf71ba | |||
e4ca12bc0f | |||
ca25159830 | |||
cb0e4ed4e6 | |||
e53d7ef230 | |||
e7da288904 | |||
eb6395a793 | |||
cd98763937 | |||
dc934f10ee | |||
7c8308a294 | |||
c00a465410 | |||
9e6990e145 | |||
81a4d5dbda | |||
5160935096 | |||
2799190b97 | |||
eea99b9466 | |||
2ba8980dd8 | |||
85c965bf92 | |||
c7185f24d4 | |||
5b740768f4 | |||
6b01841529 | |||
707398eae2 | |||
c7ca26202e | |||
b92b1c7c32 | |||
048d06ca14 | |||
56a351bbb2 | |||
f086a6e32b | |||
0a106cb952 | |||
34e82de922 | |||
40cd1de89f | |||
7ba1f22ad7 | |||
46604b755b | |||
848aa88dac | |||
4d64f290ad | |||
bcafc8f545 | |||
8d95b7d795 | |||
3d274c8189 | |||
9562bd6936 | |||
2ba5df152a | |||
f014d30a11 | |||
c57461f118 | |||
e85c910910 | |||
3d5939ed9c | |||
b1862e2a3f | |||
4c0a1c8f3f | |||
794c39ec41 | |||
cc0398f878 | |||
5023aaae75 | |||
c1068acbf7 | |||
ac81dc2099 | |||
eca2165247 | |||
213a0423d4 | |||
ef7c2f8271 | |||
f934208082 | |||
47e69da60b | |||
d4dbaa4d48 | |||
96a8c8da6c | |||
9906d82261 | |||
60b6a462c8 | |||
059dca4a40 | |||
6e463ca588 | |||
e53373ab99 | |||
d3df5fe2d3 | |||
857fa72feb | |||
bf18944761 | |||
6cecb49d50 | |||
f68571900e | |||
1d9d7853bd | |||
2f45e58369 | |||
f771c19d99 | |||
c3c6da1acc | |||
035b78a656 | |||
93367b6e70 | |||
c6a6ed963e |
9
.gitignore
vendored
9
.gitignore
vendored
@ -138,3 +138,12 @@ dmypy.json
|
|||||||
# Cython debug symbols
|
# Cython debug symbols
|
||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
|
# Ignore Dev Environment Files
|
||||||
|
db/data/
|
||||||
|
dev/
|
||||||
|
.vscode/
|
||||||
|
out/
|
||||||
|
ref-test/testing.py
|
||||||
|
|
||||||
|
# Ignore Encryption Keyfile
|
||||||
|
.encryption.key
|
41
README.md
41
README.md
@ -1,3 +1,40 @@
|
|||||||
# ska-referee-test
|
# SKA Referee Test App
|
||||||
|
|
||||||
An on-line version of a referee test for the Scottish Korfball Association.
|
## About
|
||||||
|
|
||||||
|
```A web app that digitises the theory exam for the Scottish Korfball Association referee qualification```
|
||||||
|
|
||||||
|
This web app provides an on-line platform through which to administer and take the SKA Refereeing theory exam.
|
||||||
|
The app includes a digital client to take the exam for candidates, as well as an admin console from which to manage tests, view results, and update questions.
|
||||||
|
The exam client is made with accessibility in mind, and has been designed to be adaptable to dyslexia and other learning needs or cognitive needs.
|
||||||
|
|
||||||
|
## Set Up and Installation
|
||||||
|
|
||||||
|
The clien is designed to work on a server.
|
||||||
|
|
||||||
|
### Pre-Requisites
|
||||||
|
|
||||||
|
Server
|
||||||
|
Docker
|
||||||
|
Docker-Compose
|
||||||
|
Git
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
#### Preliminary Set-Up: Clone repos and Configure Values
|
||||||
|
|
||||||
|
#### Set Up Web Server
|
||||||
|
|
||||||
|
#### Incorporate SSL
|
||||||
|
|
||||||
|
#### Set Up Auto-Renew
|
||||||
|
|
||||||
|
### Alterations
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
### iOS Limitations
|
||||||
|
|
||||||
|
### Fonts
|
||||||
|
83
REFERENCES.md
Normal file
83
REFERENCES.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# References for Learning Flask, etc
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
### Docker/Docker-Compose
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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.)
|
||||||
|
|
||||||
|
## Guides
|
||||||
|
|
||||||
|
### Flask General
|
||||||
|
|
||||||
|
- [How to structure Flask Projects](https://www.digitalocean.com/community/tutorials/how-to-structure-large-flask-applications)
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### General Flask Introduction
|
||||||
|
|
||||||
|
- [Part 1: Basics](https://www.youtube.com/watch?v=mqhxxeeTbu0)
|
||||||
|
- [Part 2: HTML Templates](https://www.youtube.com/watch?v=xIgPMguqyws)
|
||||||
|
- [Part 3: Bootstrap and Jinja](https://www.youtube.com/watch?v=4nzI4RKwb5I)
|
||||||
|
- [Part 4: HTTP Methods and Data Handling](https://www.youtube.com/watch?v=9MHYHgh4jYc)
|
||||||
|
- [Part 5: Sessions](https://www.youtube.com/watch?v=iIhAfX4iek0)
|
||||||
|
- [Part 6: Flashing Alerts](https://www.youtube.com/watch?v=qbnqNWXf_tU)
|
||||||
|
- [Part 9: Static Files](https://www.youtube.com/watch?v=tXpFERibRaU)
|
||||||
|
- [Part 10: Blueprints and Sub-Files](https://www.youtube.com/watch?v=WteIH6J9v64)
|
||||||
|
- [All in One with some more precise methods and techniques](https://www.youtube.com/watch?v=GW_2O9CrnSU)
|
||||||
|
|
||||||
|
Note: These tutorials use an outdated version of Bootstrap where some of the elements no longer apply.
|
||||||
|
|
||||||
|
### Build a User Log-In System using Python and MongoDB
|
||||||
|
|
||||||
|
- [Part 1](https://www.youtube.com/watch?v=w1STSSumoVk)
|
||||||
|
- [Part 2](https://www.youtube.com/watch?v=mISFEwojJmE)
|
||||||
|
- [Part 3](https://www.youtube.com/watch?v=tIoiR3N34i8)
|
||||||
|
- [Part 4](https://www.youtube.com/watch?v=5oC4-j3WWIk)
|
||||||
|
|
||||||
|
This method uses a basic HTML form and handles log-ins manually (rather than `flask-login` or `flask-auth`).
|
||||||
|
It uses `sessions` to handle user interactions (rather than cookies).
|
||||||
|
A better method would be to layer this with other Flask modules, such as `flask-login`, `flask-auth`, `flask-wtforms`, etc.
|
||||||
|
This tutorial is nevertheless really useful for integrating with **MongoDB**.
|
||||||
|
|
||||||
|
### Creating a User Login Session using Python, Flask, and MongoDB
|
||||||
|
|
||||||
|
- [Tutorial](https://www.youtube.com/watch?v=vVx1737auSE)
|
||||||
|
|
||||||
|
A much simpler and more rudimentary introduction to Flask and MongoDB.
|
||||||
|
|
||||||
|
### `flask-login`
|
||||||
|
|
||||||
|
- [Intro to `flask-login`](https://www.youtube.com/watch?v=2dEM-s3mRLE)
|
||||||
|
- [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.
|
||||||
|
Uses SQL rather than MongoDB.
|
||||||
|
|
||||||
|
### Flask techniques
|
||||||
|
|
||||||
|
- [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU)
|
||||||
|
|
||||||
|
### Flask handling file uploads
|
||||||
|
|
||||||
|
- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
|
2
certbot/.gitignore
vendored
Normal file
2
certbot/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
14
database/initdb.d/init-mongo.sh
Normal file
14
database/initdb.d/init-mongo.sh
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
set -e
|
||||||
|
mongo=( mongo --host 127.0.0.1 --port 27017 --quiet )
|
||||||
|
|
||||||
|
if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ] && [ "$MONGO_INITDB_USERNAME" ] && [ "$MONGO_INITDB_PASSWORD" ]; then
|
||||||
|
rootAuthDatabase='admin'
|
||||||
|
|
||||||
|
"${mongo[@]}" "$rootAuthDatabase" <<-EOJS
|
||||||
|
db.createUser({
|
||||||
|
user: $(_js_escape "$MONGO_INITDB_USERNAME"),
|
||||||
|
pwd: $(_js_escape "$MONGO_INITDB_PASSWORD"),
|
||||||
|
roles: [ { role: 'readWrite', db: $(_js_escape "$MONGO_INITDB_DATABASE") } ]
|
||||||
|
})
|
||||||
|
EOJS
|
||||||
|
fi
|
78
docker-compose.yml
Normal file
78
docker-compose.yml
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
ref_test_server:
|
||||||
|
container_name: ref_test_server
|
||||||
|
image: nginx:1.21.4-alpine
|
||||||
|
volumes:
|
||||||
|
- ./certbot:/etc/letsencrypt:ro
|
||||||
|
- ./nginx:/etc/nginx
|
||||||
|
- ./src/html:/usr/share/nginx/html/
|
||||||
|
- ./ref-test/admin/static:/usr/share/nginx/html/admin/static
|
||||||
|
- ./ref-test/quiz/static:/usr/share/nginx/html/quiz/static
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
- 443:443
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
depends_on:
|
||||||
|
- ref_test_app
|
||||||
|
|
||||||
|
ref_test_app:
|
||||||
|
container_name: ref_test_app
|
||||||
|
build: ./ref-test
|
||||||
|
volumes:
|
||||||
|
- ./ref-test:/ref-test
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
ports:
|
||||||
|
- 5000
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
- ref_test_db
|
||||||
|
- ref_test_postfix
|
||||||
|
|
||||||
|
ref_test_db:
|
||||||
|
container_name: ref_test_db
|
||||||
|
image: mongo:5.0.4-focal
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./database/data:/data
|
||||||
|
- ./database/initdb.d/:/docker-entrypoint-initdb.d/
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
ports:
|
||||||
|
- 27017
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
ref_test_postfix:
|
||||||
|
container_name: ref_test_postfix
|
||||||
|
image: catatnight/postfix:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
ports:
|
||||||
|
- 25
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
ref_test_certbot:
|
||||||
|
container_name: ref_test_certbot
|
||||||
|
image: certbot/certbot:v1.21.0
|
||||||
|
volumes:
|
||||||
|
- ./certbot:/etc/letsencrypt
|
||||||
|
- ./src/html:/var/www/html
|
||||||
|
depends_on:
|
||||||
|
- ref_test_server
|
||||||
|
# command: certonly --webroot --webroot-path=/var/www/html --email vsdomainmanager@gmail.com --agree-tos --no-eff-email -d reftest.vsnt.uk
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
external: false
|
||||||
|
backend:
|
||||||
|
external: false
|
6
nginx/certbot-challenge.conf
Normal file
6
nginx/certbot-challenge.conf
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Certbot Renewal
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
allow all;
|
||||||
|
default_type "text/plain";
|
||||||
|
}
|
6
nginx/conf.d/common-location.conf
Normal file
6
nginx/conf.d/common-location.conf
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
33
nginx/conf.d/default.conf
Normal file
33
nginx/conf.d/default.conf
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
listen 443 ssl http2 default_server;
|
||||||
|
listen [::]:443 ssl http2 default_server;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/host.access.log main;
|
||||||
|
|
||||||
|
# SSL configuration
|
||||||
|
include /etc/nginx/ssl.conf;
|
||||||
|
|
||||||
|
# Add index.php to the list if you are using PHP
|
||||||
|
index index.html index.htm index.nginx-debian.html;
|
||||||
|
|
||||||
|
# Default catch all to 404
|
||||||
|
# Added from Serverfault support https://serverfault.com/questions/994141/nginx-redirecting-the-wrong-subdomains
|
||||||
|
server_name _;
|
||||||
|
server_name_in_redirect off;
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
#error_page 404 /404.html;
|
||||||
|
|
||||||
|
# redirect server error pages to the static page /50x.html
|
||||||
|
#
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
26
nginx/fastcgi.conf
Normal file
26
nginx/fastcgi.conf
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_param QUERY_STRING $query_string;
|
||||||
|
fastcgi_param REQUEST_METHOD $request_method;
|
||||||
|
fastcgi_param CONTENT_TYPE $content_type;
|
||||||
|
fastcgi_param CONTENT_LENGTH $content_length;
|
||||||
|
|
||||||
|
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
fastcgi_param DOCUMENT_URI $document_uri;
|
||||||
|
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||||
|
fastcgi_param REQUEST_SCHEME $scheme;
|
||||||
|
fastcgi_param HTTPS $https if_not_empty;
|
||||||
|
|
||||||
|
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
|
||||||
|
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
|
||||||
|
|
||||||
|
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||||
|
fastcgi_param REMOTE_PORT $remote_port;
|
||||||
|
fastcgi_param SERVER_ADDR $server_addr;
|
||||||
|
fastcgi_param SERVER_PORT $server_port;
|
||||||
|
fastcgi_param SERVER_NAME $server_name;
|
||||||
|
|
||||||
|
# PHP only, required if PHP was built with --enable-force-cgi-redirect
|
||||||
|
fastcgi_param REDIRECT_STATUS 200;
|
25
nginx/fastcgi_params
Normal file
25
nginx/fastcgi_params
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
fastcgi_param QUERY_STRING $query_string;
|
||||||
|
fastcgi_param REQUEST_METHOD $request_method;
|
||||||
|
fastcgi_param CONTENT_TYPE $content_type;
|
||||||
|
fastcgi_param CONTENT_LENGTH $content_length;
|
||||||
|
|
||||||
|
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
fastcgi_param DOCUMENT_URI $document_uri;
|
||||||
|
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||||
|
fastcgi_param REQUEST_SCHEME $scheme;
|
||||||
|
fastcgi_param HTTPS $https if_not_empty;
|
||||||
|
|
||||||
|
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
|
||||||
|
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
|
||||||
|
|
||||||
|
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||||
|
fastcgi_param REMOTE_PORT $remote_port;
|
||||||
|
fastcgi_param SERVER_ADDR $server_addr;
|
||||||
|
fastcgi_param SERVER_PORT $server_port;
|
||||||
|
fastcgi_param SERVER_NAME $server_name;
|
||||||
|
|
||||||
|
# PHP only, required if PHP was built with --enable-force-cgi-redirect
|
||||||
|
fastcgi_param REDIRECT_STATUS 200;
|
98
nginx/mime.types
Normal file
98
nginx/mime.types
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
|
||||||
|
types {
|
||||||
|
text/html html htm shtml;
|
||||||
|
text/css css;
|
||||||
|
text/xml xml;
|
||||||
|
image/gif gif;
|
||||||
|
image/jpeg jpeg jpg;
|
||||||
|
application/javascript js;
|
||||||
|
application/atom+xml atom;
|
||||||
|
application/rss+xml rss;
|
||||||
|
|
||||||
|
text/mathml mml;
|
||||||
|
text/plain txt;
|
||||||
|
text/vnd.sun.j2me.app-descriptor jad;
|
||||||
|
text/vnd.wap.wml wml;
|
||||||
|
text/x-component htc;
|
||||||
|
|
||||||
|
image/png png;
|
||||||
|
image/svg+xml svg svgz;
|
||||||
|
image/tiff tif tiff;
|
||||||
|
image/vnd.wap.wbmp wbmp;
|
||||||
|
image/webp webp;
|
||||||
|
image/x-icon ico;
|
||||||
|
image/x-jng jng;
|
||||||
|
image/x-ms-bmp bmp;
|
||||||
|
|
||||||
|
font/woff woff;
|
||||||
|
font/woff2 woff2;
|
||||||
|
|
||||||
|
application/java-archive jar war ear;
|
||||||
|
application/json json;
|
||||||
|
application/mac-binhex40 hqx;
|
||||||
|
application/msword doc;
|
||||||
|
application/pdf pdf;
|
||||||
|
application/postscript ps eps ai;
|
||||||
|
application/rtf rtf;
|
||||||
|
application/vnd.apple.mpegurl m3u8;
|
||||||
|
application/vnd.google-earth.kml+xml kml;
|
||||||
|
application/vnd.google-earth.kmz kmz;
|
||||||
|
application/vnd.ms-excel xls;
|
||||||
|
application/vnd.ms-fontobject eot;
|
||||||
|
application/vnd.ms-powerpoint ppt;
|
||||||
|
application/vnd.oasis.opendocument.graphics odg;
|
||||||
|
application/vnd.oasis.opendocument.presentation odp;
|
||||||
|
application/vnd.oasis.opendocument.spreadsheet ods;
|
||||||
|
application/vnd.oasis.opendocument.text odt;
|
||||||
|
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||||
|
pptx;
|
||||||
|
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||||
|
xlsx;
|
||||||
|
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||||
|
docx;
|
||||||
|
application/vnd.wap.wmlc wmlc;
|
||||||
|
application/wasm wasm;
|
||||||
|
application/x-7z-compressed 7z;
|
||||||
|
application/x-cocoa cco;
|
||||||
|
application/x-java-archive-diff jardiff;
|
||||||
|
application/x-java-jnlp-file jnlp;
|
||||||
|
application/x-makeself run;
|
||||||
|
application/x-perl pl pm;
|
||||||
|
application/x-pilot prc pdb;
|
||||||
|
application/x-rar-compressed rar;
|
||||||
|
application/x-redhat-package-manager rpm;
|
||||||
|
application/x-sea sea;
|
||||||
|
application/x-shockwave-flash swf;
|
||||||
|
application/x-stuffit sit;
|
||||||
|
application/x-tcl tcl tk;
|
||||||
|
application/x-x509-ca-cert der pem crt;
|
||||||
|
application/x-xpinstall xpi;
|
||||||
|
application/xhtml+xml xhtml;
|
||||||
|
application/xspf+xml xspf;
|
||||||
|
application/zip zip;
|
||||||
|
|
||||||
|
application/octet-stream bin exe dll;
|
||||||
|
application/octet-stream deb;
|
||||||
|
application/octet-stream dmg;
|
||||||
|
application/octet-stream iso img;
|
||||||
|
application/octet-stream msi msp msm;
|
||||||
|
|
||||||
|
audio/midi mid midi kar;
|
||||||
|
audio/mpeg mp3;
|
||||||
|
audio/ogg ogg;
|
||||||
|
audio/x-m4a m4a;
|
||||||
|
audio/x-realaudio ra;
|
||||||
|
|
||||||
|
video/3gpp 3gpp 3gp;
|
||||||
|
video/mp2t ts;
|
||||||
|
video/mp4 mp4;
|
||||||
|
video/mpeg mpeg mpg;
|
||||||
|
video/quicktime mov;
|
||||||
|
video/webm webm;
|
||||||
|
video/x-flv flv;
|
||||||
|
video/x-m4v m4v;
|
||||||
|
video/x-mng mng;
|
||||||
|
video/x-ms-asf asx asf;
|
||||||
|
video/x-ms-wmv wmv;
|
||||||
|
video/x-msvideo avi;
|
||||||
|
}
|
33
nginx/nginx.conf
Normal file
33
nginx/nginx.conf
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log notice;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
#tcp_nopush on;
|
||||||
|
|
||||||
|
keepalive_timeout 65;
|
||||||
|
server_tokens off;
|
||||||
|
#gzip on;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
include /etc/nginx/conf.d/sites-enabled/*.conf;
|
||||||
|
}
|
17
nginx/scgi_params
Normal file
17
nginx/scgi_params
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
scgi_param REQUEST_METHOD $request_method;
|
||||||
|
scgi_param REQUEST_URI $request_uri;
|
||||||
|
scgi_param QUERY_STRING $query_string;
|
||||||
|
scgi_param CONTENT_TYPE $content_type;
|
||||||
|
|
||||||
|
scgi_param DOCUMENT_URI $document_uri;
|
||||||
|
scgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
scgi_param SCGI 1;
|
||||||
|
scgi_param SERVER_PROTOCOL $server_protocol;
|
||||||
|
scgi_param REQUEST_SCHEME $scheme;
|
||||||
|
scgi_param HTTPS $https if_not_empty;
|
||||||
|
|
||||||
|
scgi_param REMOTE_ADDR $remote_addr;
|
||||||
|
scgi_param REMOTE_PORT $remote_port;
|
||||||
|
scgi_param SERVER_PORT $server_port;
|
||||||
|
scgi_param SERVER_NAME $server_name;
|
17
nginx/uwsgi_params
Normal file
17
nginx/uwsgi_params
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
uwsgi_param QUERY_STRING $query_string;
|
||||||
|
uwsgi_param REQUEST_METHOD $request_method;
|
||||||
|
uwsgi_param CONTENT_TYPE $content_type;
|
||||||
|
uwsgi_param CONTENT_LENGTH $content_length;
|
||||||
|
|
||||||
|
uwsgi_param REQUEST_URI $request_uri;
|
||||||
|
uwsgi_param PATH_INFO $document_uri;
|
||||||
|
uwsgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
uwsgi_param SERVER_PROTOCOL $server_protocol;
|
||||||
|
uwsgi_param REQUEST_SCHEME $scheme;
|
||||||
|
uwsgi_param HTTPS $https if_not_empty;
|
||||||
|
|
||||||
|
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||||
|
uwsgi_param REMOTE_PORT $remote_port;
|
||||||
|
uwsgi_param SERVER_PORT $server_port;
|
||||||
|
uwsgi_param SERVER_NAME $server_name;
|
2
ref-test/.dockerignore
Normal file
2
ref-test/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
env/
|
||||||
|
__pycache__/
|
5
ref-test/Dockerfile
Normal file
5
ref-test/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM python:3.10-slim
|
||||||
|
WORKDIR /ref-test
|
||||||
|
COPY . .
|
||||||
|
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||||
|
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]
|
0
ref-test/admin/__init__.py
Normal file
0
ref-test/admin/__init__.py
Normal file
147
ref-test/admin/auth.py
Normal file
147
ref-test/admin/auth.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
from flask import Blueprint, render_template, request, session, redirect
|
||||||
|
from flask.helpers import flash, url_for
|
||||||
|
from flask.json import jsonify
|
||||||
|
from .models.users import User
|
||||||
|
from uuid import uuid4
|
||||||
|
from common.security.database import decrypt_find_one, encrypted_update
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
|
from .views import admin_account_required, disable_on_registration, login_required, disable_if_logged_in, get_id_from_cookie
|
||||||
|
|
||||||
|
auth = Blueprint(
|
||||||
|
'admin_auth',
|
||||||
|
__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static'
|
||||||
|
)
|
||||||
|
|
||||||
|
@auth.route('/account/', methods = ['GET', 'POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def account():
|
||||||
|
from .models.forms import UpdateAccountForm
|
||||||
|
from main import db
|
||||||
|
form = UpdateAccountForm()
|
||||||
|
_id = get_id_from_cookie()
|
||||||
|
user = decrypt_find_one(db.users, {'_id': _id})
|
||||||
|
if request.method == 'GET':
|
||||||
|
return render_template('/admin/auth/account.html', form = form, user = user)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
password_confirm = request.form.get('password_confirm')
|
||||||
|
if not check_password_hash(user['password'], password_confirm):
|
||||||
|
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||||
|
entry = User(
|
||||||
|
_id = _id,
|
||||||
|
password = request.form.get('password'),
|
||||||
|
email = request.form.get('email')
|
||||||
|
)
|
||||||
|
return entry.update()
|
||||||
|
else:
|
||||||
|
errors = [*form.password_confirm.errors, *form.password_reenter.errors, *form.password.errors, *form.email.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@auth.route('/login/', methods=['GET','POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@disable_if_logged_in
|
||||||
|
def login():
|
||||||
|
from .models.forms import LoginForm
|
||||||
|
form = LoginForm()
|
||||||
|
if request.method == 'GET':
|
||||||
|
return render_template('/admin/auth/login.html', form=form)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
entry = User(
|
||||||
|
username = request.form.get('username').lower(),
|
||||||
|
password = request.form.get('password'),
|
||||||
|
remember = request.form.get('remember')
|
||||||
|
)
|
||||||
|
return entry.login()
|
||||||
|
else:
|
||||||
|
errors = [*form.username.errors, *form.password.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@auth.route('/logout/')
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
_id = get_id_from_cookie()
|
||||||
|
return User(_id=_id).logout()
|
||||||
|
|
||||||
|
@auth.route('/register/', methods=['GET','POST'])
|
||||||
|
@disable_on_registration
|
||||||
|
def register():
|
||||||
|
from .models.forms import RegistrationForm
|
||||||
|
form = RegistrationForm()
|
||||||
|
if request.method == 'GET':
|
||||||
|
return render_template('/admin/auth/register.html', form=form)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
entry = User(
|
||||||
|
_id = uuid4().hex,
|
||||||
|
username = request.form.get('username').lower(),
|
||||||
|
email = request.form.get('email'),
|
||||||
|
password = request.form.get('password'),
|
||||||
|
)
|
||||||
|
return entry.register()
|
||||||
|
else:
|
||||||
|
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@auth.route('/reset/', methods = ['GET', 'POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@disable_if_logged_in
|
||||||
|
def reset():
|
||||||
|
from .models.forms import ResetPasswordForm
|
||||||
|
form = ResetPasswordForm()
|
||||||
|
if request.method == 'GET':
|
||||||
|
return render_template('/admin/auth/reset.html', form=form)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
entry = User(
|
||||||
|
username = request.form.get('username').lower(),
|
||||||
|
email = request.form.get('email'),
|
||||||
|
)
|
||||||
|
return entry.reset_password()
|
||||||
|
else:
|
||||||
|
errors = [*form.username.errors, *form.email.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@auth.route('/reset/<token1>/<token2>/', methods = ['GET'])
|
||||||
|
@admin_account_required
|
||||||
|
@disable_if_logged_in
|
||||||
|
def reset_gateway(token1,token2):
|
||||||
|
from main import db
|
||||||
|
user = decrypt_find_one( db.users, {'reset_token' : token1} )
|
||||||
|
if not user:
|
||||||
|
return redirect(url_for('admin_auth.login'))
|
||||||
|
encrypted_update( db.users, {'reset_token': token1}, {'$unset': {'reset_token' : '', 'verification_token': ''}})
|
||||||
|
if not user['verification_token'] == token2:
|
||||||
|
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error'), 401
|
||||||
|
return redirect(url_for('admin_auth.reset'))
|
||||||
|
session['_id'] = user['_id']
|
||||||
|
session['reset_validated'] = True
|
||||||
|
return redirect(url_for('admin_auth.update_password_'))
|
||||||
|
|
||||||
|
@auth.route('/reset/update/', methods = ['GET','POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@disable_if_logged_in
|
||||||
|
def update_password_():
|
||||||
|
from .models.forms import UpdatePasswordForm
|
||||||
|
form = UpdatePasswordForm()
|
||||||
|
if request.method == 'GET':
|
||||||
|
if 'reset_validated' not in session:
|
||||||
|
return redirect(url_for('admin_auth.login'))
|
||||||
|
session.pop('reset_validated')
|
||||||
|
return render_template('/admin/auth/update-password.html', form=form)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
entry = User(
|
||||||
|
_id = session['_id'],
|
||||||
|
password = request.form.get('password')
|
||||||
|
)
|
||||||
|
session.pop('_id')
|
||||||
|
return entry.update()
|
||||||
|
else:
|
||||||
|
errors = [*form.password.errors, *form.password_reenter.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
0
ref-test/admin/models/__init__.py
Normal file
0
ref-test/admin/models/__init__.py
Normal file
62
ref-test/admin/models/forms/__init__.py
Normal file
62
ref-test/admin/models/forms/__init__.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from flask_wtf.file import FileField, FileRequired, FileAllowed
|
||||||
|
from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField
|
||||||
|
from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from .validators import value
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||||
|
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
|
remember = BooleanField('Remember Log In', render_kw={'checked': True})
|
||||||
|
|
||||||
|
class RegistrationForm(FlaskForm):
|
||||||
|
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)])
|
||||||
|
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, 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.')])
|
||||||
|
|
||||||
|
class ResetPasswordForm(FlaskForm):
|
||||||
|
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)])
|
||||||
|
|
||||||
|
class UpdatePasswordForm(FlaskForm):
|
||||||
|
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, 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.')])
|
||||||
|
|
||||||
|
class CreateUserForm(FlaskForm):
|
||||||
|
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)])
|
||||||
|
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
|
|
||||||
|
class DeleteUserForm(FlaskForm):
|
||||||
|
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
|
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
|
||||||
|
|
||||||
|
class UpdateUserForm(FlaskForm):
|
||||||
|
user_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, 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)])
|
||||||
|
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, 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.')])
|
||||||
|
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
|
||||||
|
|
||||||
|
class UpdateAccountForm(FlaskForm):
|
||||||
|
password_confirm = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, 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)])
|
||||||
|
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, 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.')])
|
||||||
|
|
||||||
|
class CreateTest(FlaskForm):
|
||||||
|
start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() )
|
||||||
|
expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) )
|
||||||
|
time_limit = SelectField('Time Limit')
|
||||||
|
dataset = SelectField('Question Dataset')
|
||||||
|
|
||||||
|
class UploadDataForm(FlaskForm):
|
||||||
|
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
|
||||||
|
default = BooleanField('Make Default', render_kw={'checked': True})
|
||||||
|
|
||||||
|
class AddTimeAdjustment(FlaskForm):
|
||||||
|
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
|
||||||
|
|
11
ref-test/admin/models/forms/validators.py
Normal file
11
ref-test/admin/models/forms/validators.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from wtforms.validators import ValidationError
|
||||||
|
|
||||||
|
def value(min=0, max=None):
|
||||||
|
message = f'Value must be between {min} and {max}.'
|
||||||
|
|
||||||
|
def _length(form, field):
|
||||||
|
value = field.data or 0
|
||||||
|
if value < min or max != None and value > max:
|
||||||
|
raise ValidationError(message)
|
||||||
|
|
||||||
|
return _length
|
122
ref-test/admin/models/tests.py
Normal file
122
ref-test/admin/models/tests.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
from flask import flash, jsonify
|
||||||
|
import secrets
|
||||||
|
import os
|
||||||
|
from json import dump, loads
|
||||||
|
|
||||||
|
from common.security import encrypt
|
||||||
|
|
||||||
|
class Test:
|
||||||
|
|
||||||
|
def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None, dataset=None):
|
||||||
|
self._id = _id
|
||||||
|
self.start_date = start_date
|
||||||
|
self.expiry_date = expiry_date
|
||||||
|
self.time_limit = None if time_limit == 'none' or time_limit == '' or time_limit == None else int(time_limit)
|
||||||
|
self.creator = creator
|
||||||
|
self.dataset = dataset
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
from main import app, db
|
||||||
|
test = {
|
||||||
|
'_id': self._id,
|
||||||
|
'date_created': datetime.today(),
|
||||||
|
'start_date': self.start_date,
|
||||||
|
'expiry_date': self.expiry_date,
|
||||||
|
'time_limit': self.time_limit,
|
||||||
|
'creator': encrypt(self.creator),
|
||||||
|
'test_code': secrets.token_hex(6).upper(),
|
||||||
|
'dataset': self.dataset
|
||||||
|
}
|
||||||
|
if db.tests.insert_one(test):
|
||||||
|
dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset)
|
||||||
|
with open(dataset_file_path, 'r') as dataset_file:
|
||||||
|
data = loads(dataset_file.read())
|
||||||
|
data['meta']['tests'].append(self._id)
|
||||||
|
with open(dataset_file_path, 'w') as dataset_file:
|
||||||
|
dump(data, dataset_file, indent=2)
|
||||||
|
flash(f'Created a new exam with Exam Code <strong>{self.render_test_code(test["test_code"])}</strong>.', 'success')
|
||||||
|
return jsonify({'success': test}), 200
|
||||||
|
return jsonify({'error': f'Could not create exam. An error occurred.'}), 400
|
||||||
|
|
||||||
|
def add_time_adjustment(self, time_adjustment):
|
||||||
|
from main import db
|
||||||
|
user_code = secrets.token_hex(3).upper()
|
||||||
|
adjustment = {
|
||||||
|
user_code: time_adjustment
|
||||||
|
}
|
||||||
|
if db.tests.find_one_and_update({'_id': self._id}, {'$set': {'time_adjustments': adjustment}},upsert=False):
|
||||||
|
flash(f'Time adjustment for {time_adjustment} minutes has been added. This can be enabled using the user code {user_code}.')
|
||||||
|
return jsonify({'success': adjustment})
|
||||||
|
return jsonify({'error': 'Failed to add the time adjustment. An error occurred.'}), 400
|
||||||
|
|
||||||
|
def remove_time_adjustment(self, user_code):
|
||||||
|
from main import db
|
||||||
|
if db.tests.find_one_and_update({'_id': self._id}, {'$unset': {f'time_adjustments.{user_code}': {}}}):
|
||||||
|
message = 'Time adjustment has been deleted.'
|
||||||
|
flash(message, 'success')
|
||||||
|
return jsonify({'success': message})
|
||||||
|
return jsonify({'error': 'Failed to delete the time adjustment. An error occurred.'}), 400
|
||||||
|
|
||||||
|
def render_test_code(self, test_code):
|
||||||
|
return '—'.join([test_code[:4], test_code[4:8], test_code[8:]])
|
||||||
|
|
||||||
|
def parse_test_code(self, test_code):
|
||||||
|
return test_code.replace('—', '')
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
from main import app, db
|
||||||
|
test = db.tests.find_one({'_id': self._id})
|
||||||
|
if 'entries' in test:
|
||||||
|
if test['entries']:
|
||||||
|
return jsonify({'error': 'Cannot delete an exam that has entries submitted to it.'}), 400
|
||||||
|
if self.dataset is None:
|
||||||
|
self.dataset = db.tests.find_one({'_id': self._id})['dataset']
|
||||||
|
if db.tests.delete_one({'_id': self._id}):
|
||||||
|
dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset)
|
||||||
|
with open(dataset_file_path, 'r') as dataset_file:
|
||||||
|
data = loads(dataset_file.read())
|
||||||
|
data['meta']['tests'].remove(self._id)
|
||||||
|
with open(dataset_file_path, 'w') as dataset_file:
|
||||||
|
dump(data, dataset_file, indent=2)
|
||||||
|
message = 'Deleted exam.'
|
||||||
|
flash(message, 'alert')
|
||||||
|
return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': f'Could not create exam. An error occurred.'}), 400
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
from main import db
|
||||||
|
test = {}
|
||||||
|
updated = []
|
||||||
|
if not self.start_date == '' and self.start_date is not None:
|
||||||
|
test['start_date'] = self.start_date
|
||||||
|
updated.append('start date')
|
||||||
|
if not self.expiry_date == '' and self.expiry_date is not None:
|
||||||
|
test['expiry_date'] = self.expiry_date
|
||||||
|
updated.append('expiry date')
|
||||||
|
if not self.time_limit == '' and self.time_limit is not None:
|
||||||
|
test['time_limit'] = int(self.time_limit)
|
||||||
|
updated.append('time limit')
|
||||||
|
output = ''
|
||||||
|
if len(updated) == 0:
|
||||||
|
flash(f'There were no changes requested for your account.', 'alert'), 200
|
||||||
|
return jsonify({'success': 'There were no changes requested for your account.'}), 200
|
||||||
|
elif len(updated) == 1:
|
||||||
|
output = updated[0]
|
||||||
|
elif len(updated) == 2:
|
||||||
|
output = ' and '.join(updated)
|
||||||
|
elif len(updated) > 2:
|
||||||
|
output = updated[0]
|
||||||
|
for index in range(1,len(updated)):
|
||||||
|
if index < len(updated) - 2:
|
||||||
|
output = ', '.join([output, updated[index]])
|
||||||
|
elif index == len(updated) - 2:
|
||||||
|
output = ', and '.join([output, updated[index]])
|
||||||
|
else:
|
||||||
|
output = ''.join([output, updated[index]])
|
||||||
|
db.tests.find_one_and_update({'_id': self._id}, {'$set': test})
|
||||||
|
_output = f'The {output} of the test {"has" if len(updated) == 1 else "have"} been updated.'
|
||||||
|
flash(_output)
|
||||||
|
return jsonify({'success': _output}), 200
|
205
ref-test/admin/models/users.py
Normal file
205
ref-test/admin/models/users.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
from flask import flash, make_response, Response, session
|
||||||
|
from flask.helpers import url_for
|
||||||
|
from flask.json import jsonify
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from werkzeug.utils import redirect
|
||||||
|
from flask_mail import Message
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from common.security import encrypt, decrypt
|
||||||
|
from common.security.database import decrypt_find_one, encrypted_update
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
class User:
|
||||||
|
|
||||||
|
def __init__(self, _id=None, username=None, password=None, email=None, remember=False):
|
||||||
|
self._id = _id
|
||||||
|
self.username = username
|
||||||
|
self.email = email
|
||||||
|
self.password = password
|
||||||
|
self.remember = remember
|
||||||
|
|
||||||
|
def start_session(self, resp:Response):
|
||||||
|
resp.set_cookie(
|
||||||
|
key = '_id',
|
||||||
|
value = self._id,
|
||||||
|
max_age = timedelta(days=14) if self.remember else 'Session',
|
||||||
|
path = '/',
|
||||||
|
expires = datetime.utcnow() + timedelta(days=14) if self.remember else 'Session',
|
||||||
|
domain = '.reftest.vsnt.uk',
|
||||||
|
secure = True
|
||||||
|
)
|
||||||
|
if self.remember:
|
||||||
|
resp.set_cookie (
|
||||||
|
key = 'remember',
|
||||||
|
value = 'True',
|
||||||
|
max_age = timedelta(days=14),
|
||||||
|
path = '/',
|
||||||
|
expires = datetime.utcnow() + timedelta(days=14),
|
||||||
|
domain = '.reftest.vsnt.uk',
|
||||||
|
secure = True
|
||||||
|
)
|
||||||
|
|
||||||
|
def register(self):
|
||||||
|
from main import db
|
||||||
|
from ..views import get_id_from_cookie
|
||||||
|
user = {
|
||||||
|
'_id': self._id,
|
||||||
|
'email': encrypt(self.email),
|
||||||
|
'password': generate_password_hash(self.password, method='sha256'),
|
||||||
|
'username': encrypt(self.username)
|
||||||
|
}
|
||||||
|
if decrypt_find_one(db.users, { 'username': self.username }):
|
||||||
|
return jsonify({ 'error': f'Username {self.username} is not available.' }), 400
|
||||||
|
if db.users.insert_one(user):
|
||||||
|
flash(f'User {self.username} has been created successfully. You may now use it to log in and administer the tests.', 'success')
|
||||||
|
resp = make_response(jsonify(user), 200)
|
||||||
|
if not get_id_from_cookie:
|
||||||
|
self.start_session(resp)
|
||||||
|
return resp
|
||||||
|
return jsonify({ 'error': f'Registration failed. An error occurred.' }), 400
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
from main import db
|
||||||
|
user = decrypt_find_one( db.users, { 'username': self.username })
|
||||||
|
if not user:
|
||||||
|
return jsonify({ 'error': f'Username {self.username} does not exist.' }), 401
|
||||||
|
if not check_password_hash( user['password'], self.password ):
|
||||||
|
return jsonify({ 'error': f'The password you entered is incorrect.' }), 401
|
||||||
|
response = {
|
||||||
|
'success': f'Successfully logged in user {self.username}.'
|
||||||
|
}
|
||||||
|
if 'prev_page' in session:
|
||||||
|
response['redirect_to'] = session['prev_page']
|
||||||
|
session.pop('prev_page')
|
||||||
|
resp = make_response(jsonify(response), 200)
|
||||||
|
self._id = user['_id']
|
||||||
|
self.start_session(resp)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
resp = make_response(redirect(url_for('admin_auth.login')))
|
||||||
|
resp.set_cookie(
|
||||||
|
key = '_id',
|
||||||
|
value = '',
|
||||||
|
max_age = timedelta(days=-1),
|
||||||
|
path = '/',
|
||||||
|
expires= datetime.utcnow() + timedelta(days=-1),
|
||||||
|
domain = '.reftest.vsnt.uk',
|
||||||
|
secure = True
|
||||||
|
)
|
||||||
|
resp.set_cookie (
|
||||||
|
key = 'cookie_consent',
|
||||||
|
value = 'True',
|
||||||
|
max_age = 'Session',
|
||||||
|
path = '/',
|
||||||
|
expires = 'Session',
|
||||||
|
domain = '.reftest.vsnt.uk',
|
||||||
|
secure = True
|
||||||
|
)
|
||||||
|
resp.set_cookie (
|
||||||
|
key = 'remember',
|
||||||
|
value = 'True',
|
||||||
|
max_age = timedelta(days=-1),
|
||||||
|
path = '/',
|
||||||
|
expires = datetime.utcnow() + timedelta(days=-1),
|
||||||
|
domain = '.reftest.vsnt.uk',
|
||||||
|
secure = True
|
||||||
|
)
|
||||||
|
flash('You have been logged out. All cookies pertaining to your account have been deleted. Have a nice day.', 'alert')
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def reset_password(self):
|
||||||
|
from main import db, mail
|
||||||
|
user = decrypt_find_one(db.users, { 'username': self.username })
|
||||||
|
if not user:
|
||||||
|
return jsonify({ 'error': f'Username {self.username} does not exist.' }), 401
|
||||||
|
if not user['email'] == self.email:
|
||||||
|
return jsonify({ 'error': f'The email address {self.email} does not match the user account {self.username}.' }), 401
|
||||||
|
new_password = secrets.token_hex(12)
|
||||||
|
reset_token = secrets.token_urlsafe(16)
|
||||||
|
verification_token = secrets.token_urlsafe(16)
|
||||||
|
user['password'] = generate_password_hash(new_password, method='sha256')
|
||||||
|
if encrypted_update(db.users, { 'username': self.username }, { '$set': {'password': user['password'], 'reset_token': reset_token, 'verification_token': verification_token } } ):
|
||||||
|
flash(f'Your password has been reset. Instructions to recover your account have been sent to {self.email}. Please be sure to check your spam folder in case you have not received the email.', 'alert')
|
||||||
|
email = Message(
|
||||||
|
subject = 'RefTest | Password Reset',
|
||||||
|
recipients = [self.email],
|
||||||
|
body = f"""
|
||||||
|
Hello {user['username']}, \n\n
|
||||||
|
This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app.\n\n
|
||||||
|
If you did not make this request, please ignore this email.\n\n
|
||||||
|
If you did make this request, then you have two options to recover your account.\n\n
|
||||||
|
For the time being, your password has been reset to the following:\n\n
|
||||||
|
{new_password}\n\n
|
||||||
|
You may use this to log back in to your account, and subsequently change your password to something more suitable.\n\n
|
||||||
|
Alternatively, you may visit the following private link using your unique token to override your password. Copy and paste the following link in a web browser. Please note that this token is only valid once:\n\n
|
||||||
|
{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}\n\n
|
||||||
|
Have a nice day.
|
||||||
|
""",
|
||||||
|
html = f"""
|
||||||
|
<p>Hello {user['username']},</p>
|
||||||
|
<p>This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app. </p>
|
||||||
|
<p>If you did not make this request, please ignore this email.</p>
|
||||||
|
<p>If you did make this request, then you have two options to recover your account.</p>
|
||||||
|
<p>For the time being, your password has been reset to the following:</p>
|
||||||
|
<strong>{new_password}</strong>
|
||||||
|
<p>You may use this to log back in to your account, and subsequently change your password to something more suitable.</p>
|
||||||
|
<p>Alternatively, you may visit the following private link using your unique token to override your password. Click on the following link, or copy and paste it into a browser. <strong>Please note that this token is only valid once</strong>:</p>
|
||||||
|
<p><a href='{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}'>{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}</a></p>
|
||||||
|
<p>Have a nice day.</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(email)
|
||||||
|
return jsonify({ 'success': 'Password reset request has been processed.'}), 200
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
from main import db
|
||||||
|
from ..views import get_id_from_cookie
|
||||||
|
retrieved_user = decrypt_find_one(db.users, { '_id': self._id })
|
||||||
|
if not retrieved_user:
|
||||||
|
return jsonify({ 'error': f'User {retrieved_user["username"]} does not exist.' }), 401
|
||||||
|
user = {}
|
||||||
|
updated = []
|
||||||
|
if not self.email == '' and self.email is not None:
|
||||||
|
user['email'] = self.email
|
||||||
|
updated.append('email')
|
||||||
|
if not self.password == '' and self.password is not None:
|
||||||
|
user['password'] = generate_password_hash(self.password, method='sha256')
|
||||||
|
updated.append('password')
|
||||||
|
output = ''
|
||||||
|
if len(updated) == 0:
|
||||||
|
flash(f'There were no changes requested for your account.', 'alert'), 200
|
||||||
|
return jsonify({'success': 'There were no changes requested for your account.'}), 200
|
||||||
|
elif len(updated) == 1:
|
||||||
|
output = updated[0]
|
||||||
|
elif len(updated) == 2:
|
||||||
|
output = ' and '.join(updated)
|
||||||
|
elif len(updated) > 2:
|
||||||
|
output = updated[0]
|
||||||
|
for index in range(1,len(updated)):
|
||||||
|
if index < len(updated) - 2:
|
||||||
|
output = ', '.join([output, updated[index]])
|
||||||
|
elif index == len(updated) - 2:
|
||||||
|
output = ', and '.join([output, updated[index]])
|
||||||
|
else:
|
||||||
|
output = ''.join([output, updated[index]])
|
||||||
|
encrypted_update(db.users, {'_id': self._id}, { '$set': user })
|
||||||
|
if self._id == get_id_from_cookie():
|
||||||
|
_output = 'Your '
|
||||||
|
elif retrieved_user['username'][-1] == 's':
|
||||||
|
_output = '’'.join([retrieved_user['username'], ''])
|
||||||
|
else:
|
||||||
|
_output = '’'.join([retrieved_user['username'], 's'])
|
||||||
|
_output = f'{_output} {output} {"has" if len(updated) == 1 else "have"} been updated.'
|
||||||
|
flash(_output)
|
||||||
|
return jsonify({'success': _output}), 200
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
from main import db
|
||||||
|
retrieved_user = decrypt_find_one(db.users, { '_id': self._id })
|
||||||
|
if not retrieved_user:
|
||||||
|
return jsonify({ 'error': f'User does not exist.' }), 401
|
||||||
|
db.users.find_one_and_delete({'_id': self._id})
|
||||||
|
flash(f'User {retrieved_user["username"]} has been deleted.')
|
||||||
|
return jsonify({'success': f'User {retrieved_user["username"]} has been deleted.'}), 200
|
15
ref-test/admin/results.py
Normal file
15
ref-test/admin/results.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
from .views import login_required, admin_account_required
|
||||||
|
|
||||||
|
results = Blueprint(
|
||||||
|
'results',
|
||||||
|
__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static'
|
||||||
|
)
|
||||||
|
|
||||||
|
@results.route('/')
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def _results():
|
||||||
|
return render_template('/admin/results.html')
|
260
ref-test/admin/static/css/style.css
Normal file
260
ref-test/admin/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
body {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
background-color: lightgray;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
-ms-flex-align: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-display {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-heading {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input,
|
||||||
|
.form-label-group label {
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0; /* Override default `<label>` margin */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #495057;
|
||||||
|
cursor: text; /* Match the input under the label */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: all .1s ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0%;
|
||||||
|
border-bottom: 2px solid #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:active, .form-label-group input:focus {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-webkit-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-moz-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:not(:placeholder-shown) {
|
||||||
|
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||||
|
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||||
|
padding-top: calc(var(--input-padding-y) / 3);
|
||||||
|
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
transform: scale(1.5);
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-forgot-password {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button button, .form-submission-button a {
|
||||||
|
margin: 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTables_wrapper .dt-buttons {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
float:none;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button, .row-actions a {
|
||||||
|
margin: 0px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cookie-alert {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dismiss-cookie-alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-db-empty {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: 14pt;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input, .form-select-input {
|
||||||
|
position: relative;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input input,
|
||||||
|
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
font-size: 16pt;
|
||||||
|
width: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker::-webkit-calendar-picker-indicator {
|
||||||
|
border: 1px;
|
||||||
|
border-color: gray;
|
||||||
|
border-radius: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input label, .form-select-input label {
|
||||||
|
/* position: absolute; */
|
||||||
|
/* top: 0;
|
||||||
|
left: 0; */
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0; /* Override default `<label>` margin */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #495057;
|
||||||
|
cursor: text; /* Match the input under the label */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: all .1s ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-upload {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-action-buttons, .test-action {
|
||||||
|
margin: 5px auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Change Autocomplete styles in Chrome*/
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
textarea:-webkit-autofill,
|
||||||
|
textarea:-webkit-autofill:hover,
|
||||||
|
textarea:-webkit-autofill:focus,
|
||||||
|
select:-webkit-autofill,
|
||||||
|
select:-webkit-autofill:hover,
|
||||||
|
select:-webkit-autofill:focus {
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for Edge
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@supports (-ms-ime-align: auto) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for IE
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
BIN
ref-test/admin/static/favicon.ico
Normal file
BIN
ref-test/admin/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
ref-test/admin/static/favicon.png
Normal file
BIN
ref-test/admin/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
2
ref-test/admin/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/admin/static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
247
ref-test/admin/static/js/script.js
Normal file
247
ref-test/admin/static/js/script.js
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
// 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).attr('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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form Upload Questions - Special case, needs to handle files.
|
||||||
|
$('form[name=form-upload-questions]').submit(function(event) {
|
||||||
|
|
||||||
|
var $form = $(this);
|
||||||
|
var data = new FormData($form[0]);
|
||||||
|
var file = $('input[name=data_file]')[0].files[0]
|
||||||
|
data.append('file', file)
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: window.location.pathname,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
success: function(response) {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit and Delete Test Button Handlers
|
||||||
|
$('.test-action').click(function(event) {
|
||||||
|
|
||||||
|
let _id = $(this).data('_id');
|
||||||
|
let action = $(this).data('action');
|
||||||
|
|
||||||
|
if (action == 'delete') {
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/tests/delete/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'_id': _id}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = '/admin/tests/';
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (action == 'edit') {
|
||||||
|
window.location.href = `/admin/test/${_id}/`
|
||||||
|
} else if (action == 'close'){
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/tests/close/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'_id': _id}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit Dataset Button Handlers
|
||||||
|
$('.edit-question-dataset').click(function(event) {
|
||||||
|
|
||||||
|
var filename = $(this).data('filename');
|
||||||
|
var action = $(this).data('action');
|
||||||
|
var disabled = $(this).hasClass('disabled');
|
||||||
|
|
||||||
|
if ( !disabled ) {
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/settings/questions/${action}/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'filename': filename}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
function error_response(response) {
|
||||||
|
|
||||||
|
const $alert = $("#alert-box");
|
||||||
|
$alert.html('');
|
||||||
|
|
||||||
|
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||||
|
$alert.html(`
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
|
var output = ''
|
||||||
|
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
|
output += `
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error[i]}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
$alert.html(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$alert.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss Cookie Alert
|
||||||
|
$('#dismiss-cookie-alert').click(function(event){
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/cookies/',
|
||||||
|
type: 'GET',
|
||||||
|
data: {
|
||||||
|
time: Date.now()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
console.log(response);
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
console.log(response);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
})
|
||||||
|
|
||||||
|
// Script for Result Actions
|
||||||
|
$('.result-action-buttons').click(function(event){
|
||||||
|
|
||||||
|
var _id = $(this).data('_id');
|
||||||
|
|
||||||
|
if ($(this).data('result-action') == 'generate') {
|
||||||
|
$.ajax({
|
||||||
|
url: '/admin/certificate/',
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'_id': _id}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
dataType: 'html',
|
||||||
|
success: function(response) {
|
||||||
|
var display_window = window.open();
|
||||||
|
display_window.document.write(response);
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var action = $(this).data('result-action')
|
||||||
|
$.ajax({
|
||||||
|
url: window.location.href,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'_id': _id, 'action': action}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
if (action == 'delete') {
|
||||||
|
window.location.href = '/admin/results/';
|
||||||
|
} else window.location.reload();
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Script for Deleting Time Adjustment
|
||||||
|
$('.adjustment-delete').click(function(event){
|
||||||
|
|
||||||
|
var user_code = $(this).data('user_code');
|
||||||
|
var location = window.location.href;
|
||||||
|
location = location.replace('#', '')
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: location + 'delete-adjustment/',
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'user_code': user_code}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
56
ref-test/admin/templates/admin/auth/account.html
Normal file
56
ref-test/admin/templates/admin/auth/account.html
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{% extends "admin/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
<h2 class="form-heading">Update Your Account</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
Please confirm <strong>your current password</strong> before making any changes to your user account.
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password_confirm(class_="form-control", placeholder="Current Password", value = user.email, autofocus=true) }}
|
||||||
|
{{ form.password_confirm.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
You can use this panel to update your email address or password.
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}
|
||||||
|
{{ form.email.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password(class_="form-control", placeholder="Password") }}
|
||||||
|
{{ form.password.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
|
||||||
|
{{ form.password_reenter.label }}
|
||||||
|
</div>
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<a href="{{ url_for('admin_views.users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Cancel
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-md btn-primary btn-block" type="submit">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-lines-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zM11 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5zm.5 2.5a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1h-4zm2 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2zm0 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Update
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
32
ref-test/admin/templates/admin/auth/login.html
Normal file
32
ref-test/admin/templates/admin/auth/login.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{% extends "admin/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
<h2 class="form">Log In</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}
|
||||||
|
{{ form.username.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password(class_="form-control", placeholder="Enter Password") }}
|
||||||
|
{{ form.password.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.remember(class_="form-check-input") }}
|
||||||
|
{{ form.remember.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">Log In</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_auth.reset') }}" class="signin-forgot-password">Reset Password</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
43
ref-test/admin/templates/admin/auth/register.html
Normal file
43
ref-test/admin/templates/admin/auth/register.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{% extends "admin/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block navbar %}
|
||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
<h2 class="form-heading">Register an Account</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.username(class_="form-control", autofocus=true, placeholder="Username") }}
|
||||||
|
{{ form.username.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.email(class_="form-control", placeholder="Email Address") }}
|
||||||
|
{{ form.email.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password(class_="form-control", placeholder="Password") }}
|
||||||
|
{{ form.password.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
|
||||||
|
{{ form.password_reenter.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">Register</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
27
ref-test/admin/templates/admin/auth/reset.html
Normal file
27
ref-test/admin/templates/admin/auth/reset.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends "admin/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-reset" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
<h2 class="form-heading">Reset Password</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}
|
||||||
|
{{ form.username.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.email(class_="form-control", placeholder="Enter Email Address") }}
|
||||||
|
{{ form.email.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">Reset Password</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
29
ref-test/admin/templates/admin/auth/update-password.html
Normal file
29
ref-test/admin/templates/admin/auth/update-password.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{% extends "admin/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-update-password" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
<h2 class="form-heading">Update Password</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password(class_="form-control", placeholder="Password") }}
|
||||||
|
{{ form.password.label }}
|
||||||
|
{{ form.password.errors[0] }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
|
||||||
|
{{ form.password_reenter.label }}
|
||||||
|
{{ form.password_reenter.errors[0] }}
|
||||||
|
</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">Update Password</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
79
ref-test/admin/templates/admin/components/base.html
Normal file
79
ref-test/admin/templates/admin/components/base.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||||
|
/>
|
||||||
|
{% block datatable_css %}
|
||||||
|
{% endblock %}
|
||||||
|
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||||
|
{% include "admin/components/og-meta.html" %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
{% block navbar %}
|
||||||
|
{% include "admin/components/navbar.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="container site-footer mt-5">
|
||||||
|
{% block footer %}
|
||||||
|
{% include "admin/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
|
||||||
|
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 datatable_scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
84
ref-test/admin/templates/admin/components/certificate.html
Normal file
84
ref-test/admin/templates/admin/components/certificate.html
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Detailed Results {% endblock %}
|
||||||
|
{% block navbar %}{% endblock %}
|
||||||
|
{% block top_alerts %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<h1 class="center">SKA Referee Theory Exam Results</h1>
|
||||||
|
</div>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Candidate</h5>
|
||||||
|
</div>
|
||||||
|
<h2>
|
||||||
|
{{ entry.name.surname}}, {{ entry.name.first_name }}
|
||||||
|
</h2>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Email Address</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.email }}
|
||||||
|
</li>
|
||||||
|
{% if entry['club'] %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Club</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.club }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Exam Code</h5>
|
||||||
|
</div>
|
||||||
|
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||||
|
</li>
|
||||||
|
{% if entry['user_code'] %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">User Code</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.user_code }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Start Time</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Submission Time</h5>
|
||||||
|
{% if entry.status == 'late' %}
|
||||||
|
<span class="badge bg-danger">Late</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Score</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.results.score }}%
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action {% if entry.results.grade == 'fail' %}list-group-item-danger {% elif entry.results.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Grade</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="site-footer mt-5">
|
||||||
|
These results were generated using the SKA RefTest web app on {{ now.strftime('%d %b %Y at %H:%M:%S') }}.
|
||||||
|
</div>
|
||||||
|
{% block footer %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -0,0 +1 @@
|
|||||||
|
<div id="alert-box" tabindex="-1"></div>
|
28
ref-test/admin/templates/admin/components/datatable.html
Normal file
28
ref-test/admin/templates/admin/components/datatable.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{% extends "admin/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/admin/templates/admin/components/footer.html
Normal file
2
ref-test/admin/templates/admin/components/footer.html
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||||
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% import "bootstrap/wtf.html" as wtf %}
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% endblock %}
|
79
ref-test/admin/templates/admin/components/navbar.html
Normal file
79
ref-test/admin/templates/admin/components/navbar.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbar"
|
||||||
|
aria-controls="navbar"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle Navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if not check_login() %}
|
||||||
|
<li class="nav-item" id="nav-login">
|
||||||
|
<a href="{{ url_for('admin_auth.login') }}" id="link-login" class="nav-link">Log In</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if check_login() %}
|
||||||
|
<li class="nav-item" id="nav-results">
|
||||||
|
<a href="{{ url_for('results._results') }}" id="link-results" class="nav-link">View Results</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" id="nav-tests">
|
||||||
|
<a href="{{ url_for('admin_views.tests') }}" id="link-tests" class="nav-link">Manage Exams</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-settings">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-account"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin_views.settings') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-account"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin_views.users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin_views.questions') }}" id="link-questions" class="dropdown-item">Question Datasets</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_auth.account') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-account"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin_auth.account') }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin_auth.logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
17
ref-test/admin/templates/admin/components/og-meta.html
Normal file
17
ref-test/admin/templates/admin/components/og-meta.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<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) }}" />
|
||||||
|
<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" />
|
@ -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">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='active') }}">Active</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='scheduled') }}">Scheduled</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='expired') }}">Expired</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='all') }}">All</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='create') }}">Create</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
43
ref-test/admin/templates/admin/components/server-alerts.html
Normal file
43
ref-test/admin/templates/admin/components/server-alerts.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% set cookie_flash_flag = namespace(value=False) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
{% if category == "error" %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "success" %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "warning" %}
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "cookie_alert" %}
|
||||||
|
{% if not cookie_flash_flag.value %}
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<div class="d-flex justify-content-center w-100">
|
||||||
|
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set cookie_flash_flag.value = True %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
148
ref-test/admin/templates/admin/index.html
Normal file
148
ref-test/admin/templates/admin/index.html
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Current Exams</h5>
|
||||||
|
{% if current_tests %}
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Exam Code
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Expiry Date
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for test in current_tests %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_views.tests', filter='active') }}" class="btn btn-primary">View Exams</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary">
|
||||||
|
There are currently no active exams.
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Recent Results</h5>
|
||||||
|
{% if recent_results %}
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Date Submitted
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Result
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for result in recent_results %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin_views.view_entry', _id=result._id) }}">{{ result.name.surname }}, {{ result.name.first_name }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ result.submission_time.strftime('%d %b %Y %H:%M') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ result.percent }}% ({{ result.results.grade }})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_views.view_entries') }}" class="btn btn-primary">View Results</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary">
|
||||||
|
There are currently no exam results to preview.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Upcoming Exams</h5>
|
||||||
|
{% if upcoming_tests %}
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Exam Code
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Expiry Date
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for test in upcoming_tests %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_views.tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary">
|
||||||
|
There are currently no upcoming exams.
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<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>
|
||||||
|
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
186
ref-test/admin/templates/admin/result-detail.html
Normal file
186
ref-test/admin/templates/admin/result-detail.html
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Detailed Results {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<h1>Exam Results</h1>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Candidate</h5>
|
||||||
|
</div>
|
||||||
|
<h2>
|
||||||
|
{{ entry.name.surname }}, {{ entry.name.first_name }}
|
||||||
|
</h2>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Email Address</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.email }}
|
||||||
|
</li>
|
||||||
|
{% if entry['club'] %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Club</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.club }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Exam Code</h5>
|
||||||
|
</div>
|
||||||
|
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||||
|
</li>
|
||||||
|
{% if entry['user_code'] %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">User Code</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.user_code }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Start Time</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Submission Time</h5>
|
||||||
|
{% if entry.status == 'late' %}
|
||||||
|
<span class="badge bg-danger">Late</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if 'submission_time' in entry %}
|
||||||
|
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||||
|
{% else %}
|
||||||
|
Incomplete
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% if 'results' in entry %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Score</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.results.score }}%
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action {% if entry.results.grade == 'fail' %}list-group-item-danger {% elif entry.results.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Grade</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% if 'results' in entry %}
|
||||||
|
<div class="accordion" id="results-breakdown">
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="by-category">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#by-category-breakdown" aria-expanded="false" aria-controls="by-category-breakdown">
|
||||||
|
Score By Categories
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="by-category-breakdown" class="accordion-collapse collapse" aria-labelledby="by-category" data-bs-parent="#results-breakdown">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Category
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Score
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Max
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tag, scores in entry.results.tags.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ tag }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ scores.scored }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{scores.max}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="by-question">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#by-question-breakdown" aria-expanded="false" aria-controls="by-question-breakdown">
|
||||||
|
View All Answers
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="by-question-breakdown" class="accordion-collapse collapse" aria-labelledby="by-question" data-bs-parent="#results-breakdown">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Question
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Answer
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for question, answer in entry.answers.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ question }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ answer }}
|
||||||
|
{% if not correct[question] == answer %}
|
||||||
|
<span class="badge badge-pill bg-danger badge-danger">Incorrect</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="container justify-content-center">
|
||||||
|
<div class="row">
|
||||||
|
<a href="#" class="btn btn-primary result-action-buttons" data-result-action="generate" data-_id="{{ entry._id }}">
|
||||||
|
<i class="bi bi-printer-fill button-icon"></i>
|
||||||
|
Printable Version
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
{% if entry.status == 'late' %}
|
||||||
|
<a href="#" class="btn btn-warning result-action-buttons" data-result-action="override" data-_id="{{ entry._id }}">
|
||||||
|
<i class="bi bi-clock-history button-icon"></i>
|
||||||
|
Allow Late Entry
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="#" class="btn btn-danger result-action-buttons" data-result-action="delete" data-_id="{{ entry._id }}">
|
||||||
|
<i class="bi bi-trash-fill button-icon"></i>
|
||||||
|
Delete Result
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
138
ref-test/admin/templates/admin/results.html
Normal file
138
ref-test/admin/templates/admin/results.html
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
{% extends "admin/components/datatable.html" %}
|
||||||
|
{% block title %} SKA Referee Test | View Results {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<h1>View Results</h1>
|
||||||
|
{% if entries %}
|
||||||
|
<table id="results-table" class="table table-striped" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-priority="1">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th data-priority="4">
|
||||||
|
Club
|
||||||
|
</th>
|
||||||
|
<th data-priority="5">
|
||||||
|
Exam Code
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th data-priority="4">
|
||||||
|
Submitted
|
||||||
|
</th>
|
||||||
|
<th data-priority="2">
|
||||||
|
Result
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Grade
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Details
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in entries %}
|
||||||
|
<tr class="table-row">
|
||||||
|
<td>
|
||||||
|
{{ entry.name.surname }}, {{ entry.name.first_name }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if 'club' in entry %}
|
||||||
|
{{ entry.club }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if 'status' in entry %}
|
||||||
|
{{ entry.status }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if 'submission_time' in entry %}
|
||||||
|
{{ entry.submission_time.strftime('%d %b %Y') }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if 'results' in entry %}
|
||||||
|
{{ entry.results.score }}%
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if 'results' in entry %}
|
||||||
|
{{ entry.results.grade }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<a
|
||||||
|
href="{{ url_for('admin_views.view_entry', _id = entry._id ) }}"
|
||||||
|
class="btn btn-primary entry-details"
|
||||||
|
data-_id="{{entry._id}}"
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-medical-fill button-icon"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary alert-db-empty">
|
||||||
|
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
|
||||||
|
There are no exam attempts to view.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% if entries %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#results-table').DataTable({
|
||||||
|
'searching': false,
|
||||||
|
'columnDefs': [
|
||||||
|
{'sortable': false, 'targets': [7]},
|
||||||
|
{'searchable': false, 'targets': [7]}
|
||||||
|
],
|
||||||
|
'order': [[4, 'desc'], [0, 'asc']],
|
||||||
|
'buttons': [
|
||||||
|
{
|
||||||
|
extend: 'print',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extend: 'excel',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extend: 'pdf',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'responsive': 'true',
|
||||||
|
'colReorder': 'true',
|
||||||
|
'fixedHeader': 'true',
|
||||||
|
'searchBuilder': {
|
||||||
|
depthLimit: 2,
|
||||||
|
columns: [1, 5, 6],
|
||||||
|
},
|
||||||
|
dom: 'BQlfrtip'
|
||||||
|
});
|
||||||
|
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||||
|
} );
|
||||||
|
$('#results-table').show();
|
||||||
|
$(window).trigger('resize');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endif %}
|
44
ref-test/admin/templates/admin/settings/delete-user.html
Normal file
44
ref-test/admin/templates/admin/settings/delete-user.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{% extends "admin/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
<h2 class="form-heading">Delete User ‘{{ user.username }}’?</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<p>This action cannot be undone. Deleting an account will mean {{ user.username }} will no longer be able to log in to the admin console.</p>
|
||||||
|
<p>Are you sure you want to proceed?</p>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }}
|
||||||
|
{{ form.password.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.notify(class_="form-check-input") }}
|
||||||
|
{{ form.notify.label }}
|
||||||
|
</div>
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<a href="{{ url_for('admin_views.users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Cancel
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-md btn-danger btn-block" type="submit">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-x-fill" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm6.146-2.854a.5.5 0 0 1 .708 0L14 6.293l1.146-1.147a.5.5 0 0 1 .708.708L14.707 7l1.147 1.146a.5.5 0 0 1-.708.708L14 7.707l-1.146 1.147a.5.5 0 0 1-.708-.708L13.293 7l-1.147-1.146a.5.5 0 0 1 0-.708z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
93
ref-test/admin/templates/admin/settings/index.html
Normal file
93
ref-test/admin/templates/admin/settings/index.html
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% block title %}Settings — SKA Referee Test | Admin Console{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Admin Users</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Username
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Email Address
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="
|
||||||
|
{% if user._id == get_id_from_cookie() %}
|
||||||
|
{{ url_for('admin_auth.account') }}
|
||||||
|
{% else %}
|
||||||
|
{{ url_for('admin_views.update_user', _id=user._id) }}
|
||||||
|
{% endif%}
|
||||||
|
">{{ user.username }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_views.users') }}" class="btn btn-primary">Manage Users</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Question Datasets</h5>
|
||||||
|
{% if datasets %}
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
File Name
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Exams
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for dataset in datasets %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ dataset.filename }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ dataset.use }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Manage Datasets</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary">
|
||||||
|
There are currently no question datasets uploaded.
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Upload Dataset</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
127
ref-test/admin/templates/admin/settings/questions.html
Normal file
127
ref-test/admin/templates/admin/settings/questions.html
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
{% extends "admin/components/datatable.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Upload Questions {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<h1>Manage Question Datasets</h1>
|
||||||
|
{% if data %}
|
||||||
|
<table id="question-datasets-table" class="table table-striped" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
File Name
|
||||||
|
</th>
|
||||||
|
<th data-priority="2">
|
||||||
|
Uploaded
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Author
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Exams
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for element in data %}
|
||||||
|
<tr class="table-row">
|
||||||
|
<td>
|
||||||
|
{% if element.filename == default %}
|
||||||
|
<div class="text-success" title="Default Dataset">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ element.filename }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ element.timestamp.strftime('%d %b %Y') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ element.author }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ element.use }}
|
||||||
|
</td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-primary edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
|
||||||
|
data-filename="{{ element.filename }}"
|
||||||
|
data-action="default"
|
||||||
|
title="Make Default"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-danger edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
|
||||||
|
data-filename="{{ element.filename }}"
|
||||||
|
data-action="delete"
|
||||||
|
title="Delete Dataset"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary alert-db-empty">
|
||||||
|
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
|
||||||
|
There are no question datasets uploaded. Please use the panel below to upload a new question dataset.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-upload-questions" class="form-display" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="" enctype="multipart/form-data">
|
||||||
|
<h2 class="form-heading">Upload Question Dataset</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-upload">
|
||||||
|
{{ form.data_file() }}
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.default(class_="form-check-input") }}
|
||||||
|
{{ form.default.label }}
|
||||||
|
</div>
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button title="Create User" class="btn btn-md btn-success btn-block" type="submit">
|
||||||
|
<i class="bi bi-file-earmark-arrow-up-fill button-icon"></i>
|
||||||
|
Upload Dataset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% if data %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#question-datasets-table').DataTable({
|
||||||
|
'columnDefs': [
|
||||||
|
{'sortable': false, 'targets': [0,5]},
|
||||||
|
{'searchable': false, 'targets': [0,4,5]}
|
||||||
|
],
|
||||||
|
'order': [[2, 'desc'], [3, 'asc']],
|
||||||
|
'responsive': 'true',
|
||||||
|
'fixedHeader': 'true',
|
||||||
|
});
|
||||||
|
} );
|
||||||
|
$('#question-datasets-table').show();
|
||||||
|
$(window).trigger('resize');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endif %}
|
57
ref-test/admin/templates/admin/settings/update-user.html
Normal file
57
ref-test/admin/templates/admin/settings/update-user.html
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{% extends "admin/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
|
||||||
|
{% include "admin/components/server-alerts.html" %}
|
||||||
|
<h2 class="form-heading">Update User ‘{{ user.username }}’</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}
|
||||||
|
{{ form.email.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password(class_="form-control", placeholder="Password") }}
|
||||||
|
{{ form.password.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
|
||||||
|
{{ form.password_reenter.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.notify(class_="form-check-input") }}
|
||||||
|
{{ form.notify.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
Please confirm <strong>your password</strong> before committing any changes to a user account.
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.user_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
||||||
|
{{ form.user_password.label }}
|
||||||
|
</div>
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<a href="{{ url_for('admin_views.users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Cancel
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-md btn-primary btn-block" type="submit">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-lines-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zM11 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5zm.5 2.5a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1h-4zm2 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2zm0 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Update
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -0,0 +1 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
125
ref-test/admin/templates/admin/settings/users.html
Normal file
125
ref-test/admin/templates/admin/settings/users.html
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
{% extends "admin/components/datatable.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Manage Users {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Manage Users</h1>
|
||||||
|
<table id="user-table" class="table table-striped" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Username
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Email Address
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr class="table-row">
|
||||||
|
<td>
|
||||||
|
{% if user._id == get_id_from_cookie() %}
|
||||||
|
<div class="text-success" title="Current User">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ user.username }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ user.email }}
|
||||||
|
</td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<a
|
||||||
|
href="
|
||||||
|
{% if not user._id == get_id_from_cookie() %}
|
||||||
|
{{ url_for('admin_views.update_user', _id = user._id ) }}
|
||||||
|
{% else %}
|
||||||
|
{{ url_for('admin_auth.account') }}
|
||||||
|
{% endif %}
|
||||||
|
"
|
||||||
|
class="btn btn-primary"
|
||||||
|
title="Update User"
|
||||||
|
>
|
||||||
|
<i class="bi bi-person-lines-fill button-icon"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="
|
||||||
|
{% if not user._id == get_id_from_cookie() %}
|
||||||
|
{{ url_for('admin_views.delete_user', _id = user._id ) }}
|
||||||
|
{% else %}
|
||||||
|
#
|
||||||
|
{% endif %}
|
||||||
|
"
|
||||||
|
class="btn btn-danger {% if user._id == get_id_from_cookie() %} disabled {% endif %}"
|
||||||
|
title="Delete User"
|
||||||
|
{% if user._id == get_id_from_cookie() %} onclick="return false" {% endif %}
|
||||||
|
>
|
||||||
|
<i class="bi bi-person-x-fill button-icon"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-create-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
|
||||||
|
<h2 class="form-heading">Create User</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.username(class_="form-control", placeholder="Enter Username") }}
|
||||||
|
{{ form.username.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.email(class_="form-control", placeholder="Enter Email") }}
|
||||||
|
{{ form.email.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
If you do not enter a password, a random one will be generated.
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.password(class_="form-control", placeholder="Enter Password") }}
|
||||||
|
{{ form.password.label }}
|
||||||
|
</div>
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button title="Create User" class="btn btn-md btn-success btn-block" type="submit">
|
||||||
|
<i class="bi bi-person-plus-fill button-icon"></i>
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#user-table').DataTable({
|
||||||
|
'columnDefs': [
|
||||||
|
{'sortable': false, 'targets': [0,3]}
|
||||||
|
],
|
||||||
|
'order': [[1, 'asc'], [2, 'asc']],
|
||||||
|
'buttons': [
|
||||||
|
'copy', 'excel', 'pdf'
|
||||||
|
],
|
||||||
|
'responsive': 'true',
|
||||||
|
'colReorder': 'true',
|
||||||
|
'fixedHeader': 'true'
|
||||||
|
});
|
||||||
|
} );
|
||||||
|
$('#user-table').show();
|
||||||
|
$(window).trigger('resize');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
184
ref-test/admin/templates/admin/test.html
Normal file
184
ref-test/admin/templates/admin/test.html
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Edit Exam {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Edit Exam</h1>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Exam Code</h5>
|
||||||
|
</div>
|
||||||
|
<h2>
|
||||||
|
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
|
||||||
|
</h2>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Dataset</h5>
|
||||||
|
</div>
|
||||||
|
{{ test.dataset }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Created By</h5>
|
||||||
|
</div>
|
||||||
|
{{ test.creator }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Date Created</h5>
|
||||||
|
</div>
|
||||||
|
{{ test.date_created.strftime('%d %b %Y') }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Start Date</h5>
|
||||||
|
</div>
|
||||||
|
{{ test.start_date.strftime('%d %b %Y') }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Expiry Date</h5>
|
||||||
|
</div>
|
||||||
|
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Time Limit</h5>
|
||||||
|
</div>
|
||||||
|
{% if test.time_limit == None -%}
|
||||||
|
None
|
||||||
|
{% elif test.time_limit == 60 -%}
|
||||||
|
1 hour
|
||||||
|
{% elif test.time_limit == 90 -%}
|
||||||
|
1 hour 30 min
|
||||||
|
{% elif test.time_limit == 120 -%}
|
||||||
|
2 hours
|
||||||
|
{% else -%}
|
||||||
|
{{ test.time_limit }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
<div class="accordion" id="test-info-detail">
|
||||||
|
{% if 'entries' in test and test.entries|length > 0 %}
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="test-entries">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-entries-list" aria-expanded="false" aria-controls="test-entries-list">
|
||||||
|
List Test Entries
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="test-entries-list" class="accordion-collapse collapse" aria-labelledby="test-entries" data-bs-parent="#test-info-detail">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<tbody>
|
||||||
|
{% for entry in test.entries %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin_views.view_entry', _id=entry) }}" >Entry {{ loop.index }}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'time_adjustments' in test and test.time_adjustments|length > 0 %}
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="test-adjustments">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-adjustments-list" aria-expanded="false" aria-controls="test-adjustments-list">
|
||||||
|
List Time Adjustments
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="test-adjustments-list" class="accordion-collapse collapse" aria-labelledby="test-adjustments" data-bs-parent="#test-info-detail">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
User Code
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Adjustment (Minutes)
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Delete
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, value in test.time_adjustments.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ key }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ value }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="javascript::void(0);" class="btn btn-danger adjustment-delete" title="Delete Adjustment" data-user_code="{{ key }}">
|
||||||
|
<i class="bi bi-slash-circle-fill button-icon"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not test.time_limit == None %}
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="test-add-adjustments">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-adjustments-add" aria-expanded="false" aria-controls="test-adjustments-add">
|
||||||
|
Add Time Adjustments
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="test-adjustments-add" class="accordion-collapse collapse" aria-labelledby="test-add-adjustments" data-bs-parent="#test-info-detail">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<form name="form-add-adjustment" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.time(class_="form-control", placeholder="Enter Username") }}
|
||||||
|
{{ form.time.label }}
|
||||||
|
</div>
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button title="Add Time Adjustment" class="btn btn-md btn-success btn-block" type="submit">
|
||||||
|
<i class="bi bi-clock-history button-icon"></i>
|
||||||
|
Add Time Adjustment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
<div class="row">
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="container justify-content-center">
|
||||||
|
<div class="row">
|
||||||
|
<a href="#" class="btn btn-warning test-action" data-action="close" data-_id="{{ test._id }}">
|
||||||
|
<i class="bi bi-hourglass button-icon"></i>
|
||||||
|
Close Exam
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
Delete Exam
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
167
ref-test/admin/templates/admin/tests.html
Normal file
167
ref-test/admin/templates/admin/tests.html
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
{% extends "admin/components/datatable.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Manage Exams {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<h1>Manage Exams</h1>
|
||||||
|
{% include "admin/components/secondary-navs/tests.html" %}
|
||||||
|
<h2>{{ display_title }}</h2>
|
||||||
|
{% if tests %}
|
||||||
|
<table id="active-test-table" class="table table-striped" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-priority="1">
|
||||||
|
Start Date
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Exam Code
|
||||||
|
</th>
|
||||||
|
<th data-priority="2">
|
||||||
|
Expiry Date
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Time Limit
|
||||||
|
</th>
|
||||||
|
<th data-priority="4">
|
||||||
|
Entries
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for test in tests %}
|
||||||
|
<tr class="table-row">
|
||||||
|
<td>
|
||||||
|
{{ test.start_date.strftime('%d %b %Y') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if test.time_limit == None -%}
|
||||||
|
None
|
||||||
|
{% elif test.time_limit == 60 -%}
|
||||||
|
1 hour
|
||||||
|
{% elif test.time_limit == 90 -%}
|
||||||
|
1 hour 30 min
|
||||||
|
{% elif test.time_limit == 120 -%}
|
||||||
|
2 hours
|
||||||
|
{% else -%}
|
||||||
|
{{ test.time_limit }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ test.entries|length }}
|
||||||
|
</td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-primary test-action"
|
||||||
|
data-_id="{{test._id}}"
|
||||||
|
title="Edit Exam"
|
||||||
|
data-action="edit"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-danger test-action"
|
||||||
|
data-_id="{{test._id}}"
|
||||||
|
title="Delete Exam"
|
||||||
|
data-action="delete"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% elif not filter == 'create' %}
|
||||||
|
<div class="alert alert-primary alert-db-empty">
|
||||||
|
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
|
||||||
|
{{ error_none }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-create-test" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="/admin/tests/">
|
||||||
|
<h2 class="form-heading">Create Exam</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-date-input">
|
||||||
|
{{ form.start_date(placeholder="Enter Start Date", class_ = "datepicker") }}
|
||||||
|
{{ form.start_date.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-date-input">
|
||||||
|
{{ form.expiry_date(placeholder="Enter Expiry Date", class_ = "datepicker") }}
|
||||||
|
{{ form.expiry_date.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-select-input">
|
||||||
|
{{ form.time_limit(placeholder="Select Time Limit") }}
|
||||||
|
{{ form.time_limit.label }}
|
||||||
|
</div>
|
||||||
|
<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 title="Create Exam" class="btn btn-md btn-success btn-block" type="submit">
|
||||||
|
<i class="bi bi-file-earmark-plus-fill button-icon"></i>
|
||||||
|
Create Exam
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% if tests %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#active-test-table').DataTable({
|
||||||
|
'columnDefs': [
|
||||||
|
{'sortable': false, 'targets': [1,3,5]},
|
||||||
|
{'searchable': false, 'targets': [3,5]}
|
||||||
|
],
|
||||||
|
'order': [[0, 'desc'], [2, 'asc']],
|
||||||
|
dom: 'lfBrtip',
|
||||||
|
'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',
|
||||||
|
});
|
||||||
|
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||||
|
} );
|
||||||
|
$('#active-test-table').show();
|
||||||
|
$(window).trigger('resize');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endif %}
|
509
ref-test/admin/views.py
Normal file
509
ref-test/admin/views.py
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort, session
|
||||||
|
from flask.helpers import url_for
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
from json import loads
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
from common.security.database import decrypt_find, decrypt_find_one
|
||||||
|
from .models.users import User
|
||||||
|
from flask_mail import Message
|
||||||
|
from uuid import uuid4
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, date
|
||||||
|
from .models.tests import Test
|
||||||
|
from common.data_tools import get_default_dataset, get_time_options, available_datasets, get_datasets, get_correct_answers
|
||||||
|
|
||||||
|
views = Blueprint(
|
||||||
|
'admin_views',
|
||||||
|
__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static'
|
||||||
|
)
|
||||||
|
|
||||||
|
def admin_account_required(function):
|
||||||
|
@wraps(function)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
from main import db
|
||||||
|
from main import db
|
||||||
|
if not db.users.find_one({}):
|
||||||
|
flash('No administrator accounts have been registered. Please register an administrator account.', 'alert')
|
||||||
|
return redirect(url_for('admin_auth.register'))
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def disable_on_registration(function):
|
||||||
|
@wraps(function)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
from main import db
|
||||||
|
if db.users.find_one({}):
|
||||||
|
return abort(404)
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def get_id_from_cookie():
|
||||||
|
return request.cookies.get('_id')
|
||||||
|
|
||||||
|
def get_user_from_db(_id):
|
||||||
|
from main import db
|
||||||
|
return db.users.find_one({'_id': _id})
|
||||||
|
|
||||||
|
def check_login():
|
||||||
|
_id = get_id_from_cookie()
|
||||||
|
return True if get_user_from_db(_id) else False
|
||||||
|
|
||||||
|
def login_required(function):
|
||||||
|
@wraps(function)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not check_login():
|
||||||
|
session['prev_page'] = request.url
|
||||||
|
flash('Please log in to view this page.', 'alert')
|
||||||
|
return redirect(url_for('admin_auth.login'))
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def disable_if_logged_in(function):
|
||||||
|
@wraps(function)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if check_login():
|
||||||
|
return abort(404)
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
@views.route('/')
|
||||||
|
@views.route('/home/')
|
||||||
|
@views.route('/dashboard/')
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def home():
|
||||||
|
from main import db
|
||||||
|
tests = db.tests.find()
|
||||||
|
results = decrypt_find(db.entries, {})
|
||||||
|
current_tests = [ test for test in tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
|
||||||
|
current_tests.sort(key= lambda x: x['expiry_date'], reverse=True)
|
||||||
|
upcoming_tests = [ test for test in tests if test['start_date'] > datetime.utcnow()]
|
||||||
|
upcoming_tests.sort(key= lambda x: x['start_date'])
|
||||||
|
recent_results = [result for result in results if 'submission_time' in result ]
|
||||||
|
recent_results.sort(key= lambda x: x['submission_time'], reverse=True)
|
||||||
|
for result in recent_results:
|
||||||
|
result['percent'] = round(100*result['results']['score']/result['results']['max'])
|
||||||
|
return render_template('/admin/index.html', current_tests = current_tests[:5], upcomimg_tests = upcoming_tests[:5], recent_results = recent_results[:5])
|
||||||
|
|
||||||
|
@views.route('/settings/')
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def settings():
|
||||||
|
from main import db
|
||||||
|
users = decrypt_find(db.users, {})
|
||||||
|
users.sort(key= lambda x: x['username'])
|
||||||
|
datasets = get_datasets()
|
||||||
|
return render_template('/admin/settings/index.html', users=users[:5], datasets=datasets[:5])
|
||||||
|
|
||||||
|
@views.route('/settings/users/', methods=['GET','POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def users():
|
||||||
|
from main import db, mail
|
||||||
|
from .models.forms import CreateUserForm
|
||||||
|
form = CreateUserForm()
|
||||||
|
if request.method == 'GET':
|
||||||
|
users_list = decrypt_find(db.users, {})
|
||||||
|
return render_template('/admin/settings/users.html', users = users_list, form = form)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
entry = User(
|
||||||
|
_id = uuid4().hex,
|
||||||
|
username = request.form.get('username').lower(),
|
||||||
|
email = request.form.get('email'),
|
||||||
|
password = request.form.get('password') if not request.form.get('password') == '' else secrets.token_hex(12),
|
||||||
|
)
|
||||||
|
email = Message(
|
||||||
|
subject = 'RefTest | Registration Confirmation',
|
||||||
|
recipients = [entry.email],
|
||||||
|
body = f"""
|
||||||
|
Hello {entry.username}, \n\n
|
||||||
|
You have been registered as an administrator for the SKA RefTest App!\n\n
|
||||||
|
You can access your account using the username '{entry.username}'.\n\n
|
||||||
|
Your password is as follows:\n\n
|
||||||
|
{entry.password}\n\n
|
||||||
|
You can change your password by logging in to the admin console by copying the following URL into a web browser:\n\n
|
||||||
|
{url_for('admin_views.home', _external = True)}\n\n
|
||||||
|
Have a nice day.
|
||||||
|
""",
|
||||||
|
html = f"""
|
||||||
|
<p>Hello {entry.username},</p>
|
||||||
|
<p>You have been registered as an administrator for the SKA RefTest App!</p>
|
||||||
|
<p>You can access your account using the username '{entry.username}'.</p>
|
||||||
|
<p>Your password is as follows:</p>
|
||||||
|
<strong>{entry.password}</strong>
|
||||||
|
<p>You can change your password by logging in to the admin console at the link below:</p>
|
||||||
|
<p><a href='{url_for('admin_views.home', _external = True)}'>{url_for('admin_views.home', _external = True)}</a></p>
|
||||||
|
<p>Have a nice day.</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(email)
|
||||||
|
return entry.register()
|
||||||
|
else:
|
||||||
|
errors = [*form.username.errors, *form.email.errors, *form.password.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@views.route('/settings/users/delete/<string:_id>', methods = ['GET', 'POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def delete_user(_id:str):
|
||||||
|
from main import db, mail
|
||||||
|
if _id == get_id_from_cookie():
|
||||||
|
flash('Cannot delete your own user account.', 'error')
|
||||||
|
return redirect(url_for('admin_views.users'))
|
||||||
|
from .models.forms import DeleteUserForm
|
||||||
|
form = DeleteUserForm()
|
||||||
|
user = decrypt_find_one(db.users, {'_id': _id})
|
||||||
|
if request.method == 'GET':
|
||||||
|
if not user:
|
||||||
|
return abort(404)
|
||||||
|
return render_template('/admin/settings/delete-user.html', form = form, _id = _id, user = user)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not user:
|
||||||
|
return jsonify({ 'error': 'User does not exist.' }), 404
|
||||||
|
if form.validate_on_submit():
|
||||||
|
_user = decrypt_find_one(db.users, {'_id': get_id_from_cookie()})
|
||||||
|
password = request.form.get('password')
|
||||||
|
if not check_password_hash(_user['password'], password):
|
||||||
|
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||||
|
if request.form.get('notify'):
|
||||||
|
email = Message(
|
||||||
|
subject = 'RefTest | Account Deletion',
|
||||||
|
recipients = [user['email']],
|
||||||
|
bcc = [_user['email']],
|
||||||
|
body = f"""
|
||||||
|
Hello {user['username']}, \n\n
|
||||||
|
Your administrator account for the SKA RefTest App has been deleted by {_user['username']}. All data about your account has been deleted.\n\n
|
||||||
|
If you believe this was done in error, please contact them immediately.\n\n
|
||||||
|
If you would like to register to administer the app, please ask an existing administrator to create a new account.\n\n
|
||||||
|
Have a nice day.
|
||||||
|
""",
|
||||||
|
html = f"""
|
||||||
|
<p>Hello {user['username']},</p>
|
||||||
|
<p>Your administrator account for the SKA RefTest App has been deleted by {_user['username']}. All data about your account has been deleted.</p>
|
||||||
|
<p>If you believe this was done in error, please contact them immediately.</p>
|
||||||
|
<p>If you would like to register to administer the app, please ask an existing administrator to create a new account.</p>
|
||||||
|
<p>Have a nice day.</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(email)
|
||||||
|
user = User(
|
||||||
|
_id = user['_id']
|
||||||
|
)
|
||||||
|
return user.delete()
|
||||||
|
else: return abort(400)
|
||||||
|
|
||||||
|
@views.route('/settings/users/update/<string:_id>', methods = ['GET', 'POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def update_user(_id:str):
|
||||||
|
from main import db, mail
|
||||||
|
if _id == get_id_from_cookie():
|
||||||
|
flash('Cannot delete your own user account.', 'error')
|
||||||
|
return redirect(url_for('admin_views.users'))
|
||||||
|
from .models.forms import UpdateUserForm
|
||||||
|
form = UpdateUserForm()
|
||||||
|
user = decrypt_find_one( db.users, {'_id': _id})
|
||||||
|
if request.method == 'GET':
|
||||||
|
if not user:
|
||||||
|
return abort(404)
|
||||||
|
return render_template('/admin/settings/update-user.html', form = form, _id = _id, user = user)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not user:
|
||||||
|
return jsonify({ 'error': 'User does not exist.' }), 404
|
||||||
|
if form.validate_on_submit():
|
||||||
|
_user = decrypt_find_one(db.users, {'_id': get_id_from_cookie()})
|
||||||
|
password = request.form.get('password')
|
||||||
|
if not check_password_hash(_user['password'], password):
|
||||||
|
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||||
|
if request.form.get('notify'):
|
||||||
|
recipient = request.form.get('email') if not request.form.get('email') == '' else user['email']
|
||||||
|
email = Message(
|
||||||
|
subject = 'RefTest | Account Update',
|
||||||
|
recipients = [recipient],
|
||||||
|
bcc = [_user['email']],
|
||||||
|
body = f"""
|
||||||
|
Hello {user['username']}, \n\n
|
||||||
|
Your administrator account for the SKA RefTest App has been updated by {_user['username']}.\n\n
|
||||||
|
Your new account details are as follows:\n\n
|
||||||
|
Email: {recipient}\n
|
||||||
|
Password: {request.form.get('password')}\n\n
|
||||||
|
You can update your email and password by logging in to the app.\n\n
|
||||||
|
Have a nice day.
|
||||||
|
""",
|
||||||
|
html = f"""
|
||||||
|
<p>Hello {user['username']},</p>
|
||||||
|
<p>Your administrator account for the SKA RefTest App has been updated by {_user['username']}.</p>
|
||||||
|
<p>Your new account details are as follows:</p>
|
||||||
|
<p>Email: {recipient} <br/>Password: {request.form.get('password')}</p>
|
||||||
|
<p>You can update your email and password by logging in to the app.</p>
|
||||||
|
<p>Have a nice day.</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(email)
|
||||||
|
entry = User(
|
||||||
|
_id = _id,
|
||||||
|
email = request.form.get('email'),
|
||||||
|
password = request.form.get('password')
|
||||||
|
)
|
||||||
|
return entry.update()
|
||||||
|
else:
|
||||||
|
errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@views.route('/settings/questions/', methods=['GET', 'POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def questions():
|
||||||
|
from .models.forms import UploadDataForm
|
||||||
|
from common.data_tools import check_json_format, validate_json_contents, store_data_file
|
||||||
|
form = UploadDataForm()
|
||||||
|
if request.method == 'GET':
|
||||||
|
data = get_datasets()
|
||||||
|
default = get_default_dataset()
|
||||||
|
return render_template('/admin/settings/questions.html', form=form, data=data, default=default)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
upload = form.data_file.data
|
||||||
|
default = True if request.form.get('default') else False
|
||||||
|
if not check_json_format(upload):
|
||||||
|
return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400
|
||||||
|
if not validate_json_contents(upload):
|
||||||
|
return jsonify({'error': 'The data in the file is invalid.'}), 400
|
||||||
|
filename = store_data_file(upload, default=default)
|
||||||
|
flash(f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.', 'success')
|
||||||
|
return jsonify({ 'success': f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.'}), 200
|
||||||
|
errors = [*form.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@views.route('/settings/questions/delete/', methods=['POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def delete_questions():
|
||||||
|
from main import db, app
|
||||||
|
filename = request.get_json()['filename']
|
||||||
|
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
|
||||||
|
if any(filename in file for file in data_files):
|
||||||
|
default = get_default_dataset()
|
||||||
|
if default == filename:
|
||||||
|
return jsonify({'error': 'Cannot delete the default question dataset.'}), 400
|
||||||
|
data_file = os.path.join(app.config["DATA_FILE_DIRECTORY"],filename)
|
||||||
|
with open(data_file, 'r') as _data_file:
|
||||||
|
data = loads(_data_file.read())
|
||||||
|
if data['meta']['tests']:
|
||||||
|
return jsonify({'error': 'Cannot delete a dataset that is in use by an exam.'}), 400
|
||||||
|
if len(data_files) == 1:
|
||||||
|
return jsonify({'error': 'Cannot delete the only question dataset.'}), 400
|
||||||
|
os.remove(data_file)
|
||||||
|
flash(f'Question dataset {filename} has been deleted.', 'success')
|
||||||
|
return jsonify({'success': f'Question dataset {filename} has been deleted.'}), 200
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
@views.route('/settings/questions/default/', methods=['POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def make_default_questions():
|
||||||
|
from main import app
|
||||||
|
filename = request.get_json()['filename']
|
||||||
|
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
|
||||||
|
default_file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')
|
||||||
|
if any(filename in file for file in data_files):
|
||||||
|
with open(default_file_path, 'r') as default_file:
|
||||||
|
default = default_file.read()
|
||||||
|
if default == filename:
|
||||||
|
return jsonify({'error': 'Cannot delete default question dataset.'}), 400
|
||||||
|
with open(default_file_path, 'w') as default_file:
|
||||||
|
default_file.write(filename)
|
||||||
|
flash(f'Set dataset f{filename} as the default.', 'success')
|
||||||
|
return jsonify({'success': f'Set dataset {filename} as the default.'})
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
@views.route('/tests/<filter>/', methods=['GET'])
|
||||||
|
@views.route('/tests/', methods=['GET'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def tests(filter=''):
|
||||||
|
from main import db
|
||||||
|
if not available_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_views.questions'))
|
||||||
|
if filter not in ['', 'create', 'active', 'scheduled', 'expired', 'all']:
|
||||||
|
return abort(404)
|
||||||
|
if filter == 'create':
|
||||||
|
from .models.forms import CreateTest
|
||||||
|
form = CreateTest()
|
||||||
|
form.time_limit.choices = get_time_options()
|
||||||
|
form.dataset.choices = available_datasets()
|
||||||
|
form.time_limit.default='none'
|
||||||
|
form.dataset.default=get_default_dataset()
|
||||||
|
form.process()
|
||||||
|
display_title = ''
|
||||||
|
error_none = ''
|
||||||
|
return render_template('/admin/tests.html', form = form, display_title=display_title, error_none=error_none, filter=filter)
|
||||||
|
_tests = db.tests.find({})
|
||||||
|
if filter == 'active' or filter == '':
|
||||||
|
tests = [ test for test in _tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
|
||||||
|
display_title = 'Active Exams'
|
||||||
|
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
|
||||||
|
if filter == 'expired':
|
||||||
|
tests = [ test for test in _tests if test['expiry_date'] < datetime.utcnow()]
|
||||||
|
display_title = 'Expired Exams'
|
||||||
|
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
|
||||||
|
if filter == 'scheduled':
|
||||||
|
tests = [ test for test in _tests if test['start_date'].date() > date.today()]
|
||||||
|
display_title = 'Scheduled Exams'
|
||||||
|
error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.'
|
||||||
|
if filter == 'all':
|
||||||
|
tests = _tests
|
||||||
|
display_title = 'All Exams'
|
||||||
|
error_none = 'There are no exams set up. You can create one using the Create Exam form.'
|
||||||
|
return render_template('/admin/tests.html', tests = tests, display_title=display_title, error_none=error_none, filter=filter)
|
||||||
|
|
||||||
|
@views.route('/tests/create/', methods=['POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def create_test():
|
||||||
|
from main import db
|
||||||
|
from .models.forms import CreateTest
|
||||||
|
form = CreateTest()
|
||||||
|
form.dataset.choices = available_datasets()
|
||||||
|
form.time_limit.choices = get_time_options()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
start_date = request.form.get('start_date')
|
||||||
|
start_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
expiry_date = request.form.get('expiry_date')
|
||||||
|
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%d') + timedelta(days= 1) - timedelta(milliseconds = 1)
|
||||||
|
dataset = request.form.get('dataset')
|
||||||
|
errors = []
|
||||||
|
if start_date.date() < date.today():
|
||||||
|
errors.append('The start date cannot be in the past.')
|
||||||
|
if expiry_date.date() < date.today():
|
||||||
|
errors.append('The expiry date cannot be in the past.')
|
||||||
|
if expiry_date < start_date:
|
||||||
|
errors.append('The expiry date cannot be before the start date.')
|
||||||
|
if errors:
|
||||||
|
return jsonify({'error': errors}), 400
|
||||||
|
creator_id = get_id_from_cookie()
|
||||||
|
creator = decrypt_find_one(db.users, { '_id': creator_id } )['username']
|
||||||
|
test = Test(
|
||||||
|
_id = uuid4().hex,
|
||||||
|
start_date = start_date,
|
||||||
|
expiry_date = expiry_date,
|
||||||
|
time_limit = request.form.get('time_limit'),
|
||||||
|
creator = creator,
|
||||||
|
dataset = dataset
|
||||||
|
)
|
||||||
|
test.create()
|
||||||
|
return jsonify({'success': 'New exam created.'}), 200
|
||||||
|
else:
|
||||||
|
errors = [*form.expiry.errors, *form.time_limit.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@views.route('/tests/delete/', methods=['POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def delete_test():
|
||||||
|
from main import db
|
||||||
|
_id = request.get_json()['_id']
|
||||||
|
if db.tests.find_one({'_id': _id}):
|
||||||
|
return Test(_id = _id).delete()
|
||||||
|
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||||
|
|
||||||
|
@views.route('/tests/close/', methods=['POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def close_test():
|
||||||
|
from main import db
|
||||||
|
_id = request.get_json()['_id']
|
||||||
|
if db.tests.find_one({'_id': _id}):
|
||||||
|
return Test(_id = _id, expiry_date= datetime.utcnow()).update()
|
||||||
|
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||||
|
|
||||||
|
@views.route('/test/<_id>/', methods=['GET','POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def view_test(_id):
|
||||||
|
from main import db
|
||||||
|
from .models.forms import AddTimeAdjustment
|
||||||
|
form = AddTimeAdjustment()
|
||||||
|
test = decrypt_find_one(db.tests, {'_id': _id})
|
||||||
|
if request.method == 'GET':
|
||||||
|
if not test:
|
||||||
|
return abort(404)
|
||||||
|
return render_template('/admin/test.html', test = test, form = form)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
time = int(request.form.get('time'))
|
||||||
|
return Test(_id=_id).add_time_adjustment(time)
|
||||||
|
return jsonify({'error': form.time.errors }), 400
|
||||||
|
|
||||||
|
@views.route('/test/<_id>/delete-adjustment/', methods = ['POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def delete_adjustment(_id):
|
||||||
|
user_code = request.get_json()['user_code']
|
||||||
|
return Test(_id=_id).remove_time_adjustment(user_code)
|
||||||
|
|
||||||
|
@views.route('/results/')
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def view_entries():
|
||||||
|
from main import db
|
||||||
|
entries = decrypt_find(db.entries, {})
|
||||||
|
return render_template('/admin/results.html', entries = entries)
|
||||||
|
|
||||||
|
@views.route('/results/<_id>/', methods = ['GET', 'POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def view_entry(_id=''):
|
||||||
|
from main import app, db
|
||||||
|
entry = decrypt_find_one(db.entries, {'_id': _id})
|
||||||
|
if request.method == 'GET':
|
||||||
|
if not entry:
|
||||||
|
return abort(404)
|
||||||
|
test_code = entry['test_code']
|
||||||
|
test = db.tests.find_one({'test_code' : test_code})
|
||||||
|
dataset = test['dataset']
|
||||||
|
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
|
||||||
|
with open(dataset_path, 'r') as _dataset:
|
||||||
|
data = loads(_dataset.read())
|
||||||
|
correct = get_correct_answers(dataset=data)
|
||||||
|
print(correct.values())
|
||||||
|
return render_template('/admin/result-detail.html', entry = entry, correct = correct)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not entry:
|
||||||
|
return jsonify({'error': 'A valid entry could no be found.'}), 404
|
||||||
|
action = request.get_json()['action']
|
||||||
|
if action == 'override':
|
||||||
|
late_ignore = db.entries.find_one_and_update({'_id': _id}, {'$set': {'status': 'late (allowed)'}})
|
||||||
|
if late_ignore:
|
||||||
|
flash('Late status for the entry has been allowed.', 'success')
|
||||||
|
return jsonify({'success': 'Late status allowed.'}), 200
|
||||||
|
return jsonify({'error': 'An error occurred.'}), 400
|
||||||
|
if action == 'delete':
|
||||||
|
test_code = entry['test_code']
|
||||||
|
test = db.tests.find_one_and_update({'test_code': test_code}, {'$pull': {'entries': _id}})
|
||||||
|
if not test:
|
||||||
|
return jsonify({'error': 'A valid exam could not be found.'}), 404
|
||||||
|
delete = db.entries.delete_one({'_id': _id})
|
||||||
|
if delete:
|
||||||
|
flash('Entry has been deleted.', 'success')
|
||||||
|
return jsonify({'success': 'Entry has been deleted.'}), 200
|
||||||
|
return jsonify({'error': 'An error occurred.'}), 400
|
||||||
|
|
||||||
|
@views.route('/certificate/', methods=['POST'])
|
||||||
|
@admin_account_required
|
||||||
|
@login_required
|
||||||
|
def generate_certificate():
|
||||||
|
from main import db
|
||||||
|
_id = request.get_json()['_id']
|
||||||
|
entry = decrypt_find_one(db.entries, {'_id': _id})
|
||||||
|
if not entry:
|
||||||
|
return abort(404)
|
||||||
|
return render_template('/admin/components/certificate.html', entry = entry)
|
0
ref-test/common/__init__.py
Normal file
0
ref-test/common/__init__.py
Normal file
20
ref-test/common/blueprints.py
Normal file
20
ref-test/common/blueprints.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import Blueprint, redirect, request
|
||||||
|
|
||||||
|
cookie_consent = Blueprint(
|
||||||
|
'cookie_consent',
|
||||||
|
__name__
|
||||||
|
)
|
||||||
|
@cookie_consent.route('/')
|
||||||
|
def _cookies():
|
||||||
|
resp = redirect('/')
|
||||||
|
resp.set_cookie(
|
||||||
|
key = 'cookie_consent',
|
||||||
|
value = 'True',
|
||||||
|
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session',
|
||||||
|
path = '/',
|
||||||
|
expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session',
|
||||||
|
domain = '.reftest.vsnt.uk',
|
||||||
|
secure = True
|
||||||
|
)
|
||||||
|
return resp
|
236
ref-test/common/data_tools.py
Normal file
236
ref-test/common/data_tools.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
from json import dump, loads
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from glob import glob
|
||||||
|
from random import shuffle
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from .security.database import decrypt_find_one
|
||||||
|
|
||||||
|
def check_data_folder_exists():
|
||||||
|
from main import app
|
||||||
|
if not os.path.exists(app.config['DATA_FILE_DIRECTORY']):
|
||||||
|
pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True')
|
||||||
|
|
||||||
|
def check_default_indicator():
|
||||||
|
from main import app
|
||||||
|
if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')):
|
||||||
|
open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt'),'w').close()
|
||||||
|
|
||||||
|
def get_default_dataset():
|
||||||
|
check_default_indicator()
|
||||||
|
from main import app
|
||||||
|
default_file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')
|
||||||
|
with open(default_file_path, 'r') as default_file:
|
||||||
|
default = default_file.read()
|
||||||
|
return default
|
||||||
|
|
||||||
|
def available_datasets():
|
||||||
|
from main import app
|
||||||
|
files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
|
||||||
|
default = get_default_dataset()
|
||||||
|
output = []
|
||||||
|
for file in files:
|
||||||
|
filename = file.rsplit('/')[-1]
|
||||||
|
label = f'{filename[:-5]} (Default)' if filename == default else filename[:-5]
|
||||||
|
element = (filename, label)
|
||||||
|
output.append(element)
|
||||||
|
output.reverse()
|
||||||
|
return output
|
||||||
|
|
||||||
|
def check_json_format(file):
|
||||||
|
if not '.' in file.filename:
|
||||||
|
return False
|
||||||
|
if not file.filename.rsplit('.', 1)[-1] == 'json':
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_json_contents(file):
|
||||||
|
file.stream.seek(0)
|
||||||
|
data = loads(file.read())
|
||||||
|
if not type(data) is dict:
|
||||||
|
return False
|
||||||
|
elif not all( key in data for key in ['meta', 'questions']):
|
||||||
|
return False
|
||||||
|
elif not type(data['meta']) is dict:
|
||||||
|
return False
|
||||||
|
elif not type(data['questions']) is list:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def store_data_file(file, default:bool=None):
|
||||||
|
from admin.views import get_id_from_cookie
|
||||||
|
from main import app
|
||||||
|
check_default_indicator()
|
||||||
|
timestamp = datetime.utcnow()
|
||||||
|
filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json'])
|
||||||
|
filename = secure_filename(filename)
|
||||||
|
file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename)
|
||||||
|
file.stream.seek(0)
|
||||||
|
data = loads(file.read())
|
||||||
|
data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S')
|
||||||
|
data['meta']['author'] = get_id_from_cookie()
|
||||||
|
data['meta']['tests'] = []
|
||||||
|
with open(file_path, 'w') as _file:
|
||||||
|
dump(data, _file, indent=2)
|
||||||
|
if default:
|
||||||
|
with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt'), 'w') as _file:
|
||||||
|
_file.write(filename)
|
||||||
|
return filename
|
||||||
|
|
||||||
|
def randomise_list(list:list):
|
||||||
|
_list = list.copy()
|
||||||
|
shuffle(_list)
|
||||||
|
return(_list)
|
||||||
|
|
||||||
|
def generate_questions(dataset:dict):
|
||||||
|
questions_list = dataset['questions']
|
||||||
|
output = []
|
||||||
|
for block in randomise_list(questions_list):
|
||||||
|
if block['type'] == 'question':
|
||||||
|
question = {
|
||||||
|
'type': 'question',
|
||||||
|
'q_no': block['q_no'],
|
||||||
|
'question_header': '',
|
||||||
|
'text': block['text']
|
||||||
|
}
|
||||||
|
if block['q_type'] == 'Multiple Choice':
|
||||||
|
question['options'] = randomise_list(block['options'])
|
||||||
|
else:
|
||||||
|
question['options'] = block['options'].copy()
|
||||||
|
output.append(question)
|
||||||
|
if block['type'] == 'block':
|
||||||
|
for key, _question in enumerate(randomise_list(block['questions'])):
|
||||||
|
question = {
|
||||||
|
'type': 'block',
|
||||||
|
'q_no': _question['q_no'],
|
||||||
|
'question_header': block['question_header'] if 'question_header' in block else '',
|
||||||
|
'block_length': len(block['questions']),
|
||||||
|
'block_q_no': key,
|
||||||
|
'text': _question['text']
|
||||||
|
}
|
||||||
|
if _question['q_type'] == 'Multiple Choice':
|
||||||
|
question['options'] = randomise_list(_question['options'])
|
||||||
|
else:
|
||||||
|
question['options'] = _question['options'].copy()
|
||||||
|
output.append(question)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def evaluate_answers(dataset: dict, answers: dict):
|
||||||
|
score = 0
|
||||||
|
max = 0
|
||||||
|
tags = {}
|
||||||
|
for block in dataset['questions']:
|
||||||
|
if block['type'] == 'question':
|
||||||
|
max += 1
|
||||||
|
q_no = block['q_no']
|
||||||
|
if str(q_no) in answers:
|
||||||
|
correct = block['correct']
|
||||||
|
correct_answer = block['options'][correct]
|
||||||
|
if answers[str(q_no)] == correct_answer:
|
||||||
|
score += 1
|
||||||
|
for tag in block['tags']:
|
||||||
|
if tag not in tags:
|
||||||
|
tags[tag] = {
|
||||||
|
'scored': 1,
|
||||||
|
'max': 1
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
tags[tag]['scored'] += 1
|
||||||
|
tags[tag]['max'] += 1
|
||||||
|
else:
|
||||||
|
for tag in block['tags']:
|
||||||
|
if tag not in tags:
|
||||||
|
tags[tag] = {
|
||||||
|
'scored': 0,
|
||||||
|
'max': 1
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
tags[tag]['max'] += 1
|
||||||
|
if block['type'] == 'block':
|
||||||
|
for question in block['questions']:
|
||||||
|
max += 1
|
||||||
|
q_no = question['q_no']
|
||||||
|
if str(q_no) in answers:
|
||||||
|
correct = question['correct']
|
||||||
|
correct_answer = question['options'][correct]
|
||||||
|
if answers[str(q_no)] == correct_answer:
|
||||||
|
score += 1
|
||||||
|
for tag in question['tags']:
|
||||||
|
if tag not in tags:
|
||||||
|
tags[tag] = {
|
||||||
|
'scored': 1,
|
||||||
|
'max': 1
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
tags[tag]['scored'] += 1
|
||||||
|
tags[tag]['max'] += 1
|
||||||
|
else:
|
||||||
|
for tag in question['tags']:
|
||||||
|
if tag not in tags:
|
||||||
|
tags[tag] = {
|
||||||
|
'scored': 0,
|
||||||
|
'max': 1
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
tags[tag]['max'] += 1
|
||||||
|
|
||||||
|
grade = 'merit' if score/max >= .85 else 'pass' if score/max >= .70 else 'fail'
|
||||||
|
return {
|
||||||
|
'grade': grade,
|
||||||
|
'tags': tags,
|
||||||
|
'score': score,
|
||||||
|
'max': max
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_tags_list(dataset:dict):
|
||||||
|
output = []
|
||||||
|
blocks = dataset['questions']
|
||||||
|
for block in blocks:
|
||||||
|
if block['type'] == 'question':
|
||||||
|
output = list(set(output) | set(block['tags']))
|
||||||
|
if block['type'] == 'block':
|
||||||
|
for question in block['questions']:
|
||||||
|
output = list(set(output) | set(question['tags']))
|
||||||
|
return output
|
||||||
|
|
||||||
|
def get_time_options():
|
||||||
|
time_options = [
|
||||||
|
('none', 'None'),
|
||||||
|
('60', '1 hour'),
|
||||||
|
('90', '1 hour 30 minutes'),
|
||||||
|
('120', '2 hours')
|
||||||
|
]
|
||||||
|
return time_options
|
||||||
|
|
||||||
|
def get_datasets():
|
||||||
|
from main import app, db
|
||||||
|
files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
|
||||||
|
data = []
|
||||||
|
if files:
|
||||||
|
for file in files:
|
||||||
|
filename = file.rsplit('/')[-1]
|
||||||
|
with open(file) as _file:
|
||||||
|
load = loads(_file.read())
|
||||||
|
_author = load['meta']['author']
|
||||||
|
author = decrypt_find_one(db.users, {'_id': _author})['username']
|
||||||
|
data_element = {
|
||||||
|
'filename': filename,
|
||||||
|
'timestamp': datetime.strptime(load['meta']['timestamp'], '%Y-%m-%d %H%M%S'),
|
||||||
|
'author': author,
|
||||||
|
'use': len(load['meta']['tests'])
|
||||||
|
}
|
||||||
|
data.append(data_element)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_correct_answers(dataset:dict):
|
||||||
|
output = {}
|
||||||
|
blocks = dataset['questions']
|
||||||
|
for block in blocks:
|
||||||
|
if block['type'] == 'question':
|
||||||
|
output[str(block['q_no'])] = block['options'][block['correct']]
|
||||||
|
if block['type'] == 'block':
|
||||||
|
for question in block['questions']:
|
||||||
|
output[str(question['q_no'])] = question['options'][question['correct']]
|
||||||
|
return output
|
60
ref-test/common/security/__init__.py
Normal file
60
ref-test/common/security/__init__.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from os import environ, path
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
def generate_keyfile():
|
||||||
|
with open('./common/security/.encryption.key', 'wb') as keyfile:
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
keyfile.write(key)
|
||||||
|
|
||||||
|
def load_key():
|
||||||
|
with open('./common/security/.encryption.key', 'rb') as keyfile:
|
||||||
|
key = keyfile.read()
|
||||||
|
return key
|
||||||
|
|
||||||
|
def check_keyfile_exists():
|
||||||
|
return path.isfile('./common/security/.encryption.key')
|
||||||
|
|
||||||
|
def encrypt(input):
|
||||||
|
if not check_keyfile_exists():
|
||||||
|
generate_keyfile()
|
||||||
|
_encryption_key = load_key()
|
||||||
|
fernet = Fernet(_encryption_key)
|
||||||
|
if type(input) == str:
|
||||||
|
input = input.encode()
|
||||||
|
output = fernet.encrypt(input)
|
||||||
|
return output.decode()
|
||||||
|
if type(input) == dict:
|
||||||
|
output = {}
|
||||||
|
for key,value in input.items():
|
||||||
|
if type(value) == dict:
|
||||||
|
output[key] = encrypt(value)
|
||||||
|
else:
|
||||||
|
value = value.encode()
|
||||||
|
output[key] = fernet.encrypt(value)
|
||||||
|
output[key] = output[key].decode()
|
||||||
|
return output
|
||||||
|
|
||||||
|
def decrypt(input):
|
||||||
|
if not check_keyfile_exists():
|
||||||
|
raise EncryptionKeyMissing
|
||||||
|
_encryption_key = load_key()
|
||||||
|
fernet = Fernet(_encryption_key)
|
||||||
|
if type(input) == str:
|
||||||
|
input = input.encode()
|
||||||
|
output = fernet.decrypt(input)
|
||||||
|
return output.decode()
|
||||||
|
if type(input) == dict:
|
||||||
|
output = {}
|
||||||
|
for key, value in input.items():
|
||||||
|
if type(value) == dict:
|
||||||
|
output[key] = decrypt(value)
|
||||||
|
else:
|
||||||
|
value = value.encode()
|
||||||
|
output[key] = fernet.decrypt(value)
|
||||||
|
output[key] = output[key].decode()
|
||||||
|
return output
|
||||||
|
|
||||||
|
class EncryptionKeyMissing(Exception):
|
||||||
|
def __init__(self, message='There is no encryption keyfile.'):
|
||||||
|
self.message = message
|
||||||
|
super().__init__(self.message)
|
46
ref-test/common/security/database.py
Normal file
46
ref-test/common/security/database.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from pymongo import collection
|
||||||
|
from . import encrypt, decrypt
|
||||||
|
encrypted_parameters = ['username', 'email', 'name', 'club', 'creator']
|
||||||
|
|
||||||
|
def decrypt_find(collection:collection, query:dict):
|
||||||
|
cursor = collection.find({})
|
||||||
|
output_list = []
|
||||||
|
for document in cursor:
|
||||||
|
decrypted_document = {}
|
||||||
|
for key in document:
|
||||||
|
if key not in encrypted_parameters:
|
||||||
|
decrypted_document[key] = document[key]
|
||||||
|
else:
|
||||||
|
decrypted_document[key] = decrypt(document[key])
|
||||||
|
if not query:
|
||||||
|
output_list.append(decrypted_document)
|
||||||
|
else:
|
||||||
|
if query.items() <= decrypted_document.items():
|
||||||
|
output_list.append(decrypted_document)
|
||||||
|
return output_list
|
||||||
|
|
||||||
|
def decrypt_find_one(collection:collection, query:dict={}):
|
||||||
|
cursor = decrypt_find(collection=collection, query=query)
|
||||||
|
if cursor: return cursor[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def encrypted_update(collection:collection, query:dict={}, update:dict={}):
|
||||||
|
document = decrypt_find_one(collection=collection, query=query)
|
||||||
|
for update_action in update:
|
||||||
|
key_pairs = update[update_action]
|
||||||
|
if type(key_pairs) is not dict:
|
||||||
|
raise ValueError
|
||||||
|
if update_action == '$set':
|
||||||
|
for key in key_pairs:
|
||||||
|
if key == '_id':
|
||||||
|
raise ValueError
|
||||||
|
document[key] = key_pairs[key]
|
||||||
|
if update_action == '$unset':
|
||||||
|
for key in key_pairs:
|
||||||
|
if key == '_id':
|
||||||
|
raise ValueError
|
||||||
|
if key in document:
|
||||||
|
del document[key]
|
||||||
|
for key in document:
|
||||||
|
document[key] = encrypt(document[key]) if key in encrypted_parameters else document[key]
|
||||||
|
return collection.find_one_and_replace( { '_id': document['_id'] }, document)
|
51
ref-test/config.py
Normal file
51
ref-test/config.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
class Config(object):
|
||||||
|
DEBUG = False
|
||||||
|
TESTING = False
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||||
|
SERVER_NAME = os.getenv('SERVER_NAME')
|
||||||
|
|
||||||
|
MONGO_INITDB_DATABASE = os.getenv('MONGO_INITDB_DATABASE')
|
||||||
|
from urllib import parse
|
||||||
|
MONGO_URI = f'mongodb://{os.getenv("MONGO_INITDB_USERNAME")}:{parse.quote_plus(os.getenv("MONGO_INITDB_PASSWORD"))}@{os.getenv("MONGO_DB_HOST_ALIAS")}:{os.getenv("MONGO_PORT")}/'
|
||||||
|
|
||||||
|
APP_HOST = '0.0.0.0'
|
||||||
|
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
|
||||||
|
MAIL_SERVER = os.getenv("MAIL_SERVER")
|
||||||
|
MAIL_PORT = int(os.getenv("MAIL_PORT"))
|
||||||
|
MAIL_USE_TLS = False
|
||||||
|
MAIL_USE_SSL = False
|
||||||
|
MAIL_DEBUG = False
|
||||||
|
MAIL_USERNAME = os.getenv("MAIL_USERNAME")
|
||||||
|
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD")
|
||||||
|
MAIL_DEFAULT_SENDER = os.getenv("MAIL_DEFAULT_SENDER")
|
||||||
|
MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS"))
|
||||||
|
MAIL_SUPPRESS_SEND = False
|
||||||
|
MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS"))
|
||||||
|
DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY")
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
DEBUG = True
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
MONGO_INITDB_DATABASE = os.getenv('MONGO_INITDB_DATABASE')
|
||||||
|
from urllib import parse
|
||||||
|
MONGO_URI = f'mongodb://{os.getenv("MONGO_INITDB_USERNAME")}:{parse.quote_plus(os.getenv("MONGO_INITDB_PASSWORD"))}@localhost:{os.getenv("MONGO_PORT")}/'
|
||||||
|
APP_HOST = '127.0.0.1'
|
||||||
|
MAIL_SERVER = 'localhost'
|
||||||
|
MAIL_DEBUG = True
|
||||||
|
MAIL_SUPPRESS_SEND = False
|
||||||
|
|
||||||
|
class TestingConfig(DevelopmentConfig):
|
||||||
|
TESTING = True
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
MAIL_SERVER = os.getenv("MAIL_SERVER")
|
||||||
|
MAIL_DEBUG = True
|
||||||
|
MAIL_SUPPRESS_SEND = False
|
||||||
|
from urllib import parse
|
||||||
|
MONGO_URI = f'mongodb://{os.getenv("MONGO_INITDB_USERNAME")}:{parse.quote_plus(os.getenv("MONGO_INITDB_PASSWORD"))}@{os.getenv("MONGO_DB_HOST_ALIAS")}:{os.getenv("MONGO_PORT")}/'
|
2
ref-test/data/.gitignore
vendored
Normal file
2
ref-test/data/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
84
ref-test/main.py
Normal file
84
ref-test/main.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Flask, flash, request, render_template
|
||||||
|
from flask.helpers import url_for
|
||||||
|
from flask.json import jsonify
|
||||||
|
from flask_bootstrap import Bootstrap
|
||||||
|
from pymongo import MongoClient
|
||||||
|
from pymongo.errors import ConnectionFailure
|
||||||
|
from flask_wtf.csrf import CSRFProtect, CSRFError
|
||||||
|
from flask_mail import Mail
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
|
from common.security import check_keyfile_exists, generate_keyfile
|
||||||
|
import config
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config.ProductionConfig())
|
||||||
|
|
||||||
|
from common.blueprints import cookie_consent
|
||||||
|
|
||||||
|
from admin.views import views as admin_views
|
||||||
|
from admin.auth import auth as admin_auth
|
||||||
|
from admin.results import results
|
||||||
|
from quiz.views import views as quiz_views
|
||||||
|
|
||||||
|
app.register_blueprint(quiz_views, url_prefix = '/')
|
||||||
|
app.register_blueprint(admin_views, url_prefix = '/admin/')
|
||||||
|
app.register_blueprint(admin_auth, url_prefix = '/admin/')
|
||||||
|
app.register_blueprint(results, url_prefix = '/admin/results/')
|
||||||
|
|
||||||
|
app.register_blueprint(cookie_consent, url_prefix = '/cookies/')
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def check_cookie_consent():
|
||||||
|
if request.cookies.get('cookie_consent') == 'True':
|
||||||
|
return
|
||||||
|
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/static/', '/cookies/' ] ]):
|
||||||
|
return
|
||||||
|
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("quiz_views.privacy")}">privacy policy</a>.', 'cookie_alert')
|
||||||
|
|
||||||
|
from admin.views import check_login, get_user_from_db, get_id_from_cookie
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_now():
|
||||||
|
return {'now': datetime.utcnow()}
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def _check_login():
|
||||||
|
return dict(check_login = check_login)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def _get_user_from_db():
|
||||||
|
return dict(get_user_from_db = get_user_from_db)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def _get_id_from_cookie():
|
||||||
|
return dict(get_id_from_cookie = get_id_from_cookie)
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def _404_handler(e):
|
||||||
|
return render_template('/quiz/404.html'), 404
|
||||||
|
|
||||||
|
@app.errorhandler(CSRFError)
|
||||||
|
def csrf_error_handler(error):
|
||||||
|
return jsonify({ 'error': 'Could not validate a secure connection.'} ), 400
|
||||||
|
|
||||||
|
if not check_keyfile_exists():
|
||||||
|
generate_keyfile()
|
||||||
|
|
||||||
|
Bootstrap(app)
|
||||||
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
mongo = MongoClient(app.config['MONGO_URI'])
|
||||||
|
db = mongo[app.config['MONGO_INITDB_DATABASE']]
|
||||||
|
mail = Mail(app)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host=app.config['APP_HOST'])
|
0
ref-test/quiz/__init__.py
Normal file
0
ref-test/quiz/__init__.py
Normal file
11
ref-test/quiz/forms.py
Normal file
11
ref-test/quiz/forms.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, PasswordField, BooleanField
|
||||||
|
from wtforms.validators import InputRequired, Email, Length, Optional
|
||||||
|
|
||||||
|
class StartQuiz(FlaskForm):
|
||||||
|
first_name = StringField('First Name(s)', validators=[InputRequired(), Length(max=30)])
|
||||||
|
surname = StringField('Surname', validators=[InputRequired(), Length(max=30)])
|
||||||
|
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
|
club = StringField('Affiliated Club (Optional)', validators=[Optional(), Length(max=50)])
|
||||||
|
test_code = StringField('Exam Code', validators=[InputRequired(), Length(min=14, max=14)])
|
||||||
|
user_code = StringField('User Code (Optional)', validators=[Optional(), Length(min=6, max=6)])
|
158
ref-test/quiz/static/css/quiz.css
Normal file
158
ref-test/quiz/static/css/quiz.css
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/*! Generated by Font Squirrel (https://www.fontsquirrel.com) on November 23, 2021 */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'opendyslexic3bold';
|
||||||
|
src: url('../fonts/opendyslexic3-bold-webfont.woff2') format('woff2'),
|
||||||
|
url('../fonts/opendyslexic3-bold-webfont.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'opendyslexic3regular';
|
||||||
|
src: url('../fonts/opendyslexic3-regular-webfont.woff2') format('woff2'),
|
||||||
|
url('../fonts/opendyslexic3-regular-webfont.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'opendyslexicmonoregular';
|
||||||
|
src: url('../fonts/opendyslexicmono-regular-webfont.woff2') format('woff2'),
|
||||||
|
url('../fonts/opendyslexicmono-regular-webfont.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Class Definitions */
|
||||||
|
|
||||||
|
.form-quiz-configure {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-opendyslexic {
|
||||||
|
font-family: 'opendyslexic3bold';
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-comicsans {
|
||||||
|
font-family: 'Comic Sans MS', 'Comic Sans';
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-osdefault {
|
||||||
|
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-verdana {
|
||||||
|
font-family: Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-tahoma {
|
||||||
|
font-family: Tahoma, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-arial {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-12pt {
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-14pt {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-16pt {
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-18pt {
|
||||||
|
font-size: 18pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-settings-element {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-bg-light-1 {
|
||||||
|
background-color: beige;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-bg-light-2 {
|
||||||
|
background-color: #EBE3E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sample-question {
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-container {
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 2 rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-title {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-header {
|
||||||
|
margin: 1rem auto 3rem;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quiz-control {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
#q-topbar a.btn {
|
||||||
|
padding: 2px 6px 0px 6px;
|
||||||
|
font-size: 14pt;
|
||||||
|
height: fit-content;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-timer {
|
||||||
|
padding-top: 0px;
|
||||||
|
margin: 0px auto;
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-navigator-button {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button-container {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button-container a {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigator-help {
|
||||||
|
margin: 4rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigator-container {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout for Mobile Devices */
|
||||||
|
@media only screen and (max-width: 576px) {
|
||||||
|
body {
|
||||||
|
padding-top: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .container {
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
}
|
218
ref-test/quiz/static/css/style.css
Normal file
218
ref-test/quiz/static/css/style.css
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
body {
|
||||||
|
padding: 80px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cookie-alert {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dismiss-cookie-alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-container {
|
||||||
|
margin: 2rem auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-container {
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
background-color: lightgray;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-container {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
-ms-flex-align: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-quiz-start {
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-name {
|
||||||
|
margin: 3rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-name .surname {
|
||||||
|
font-variant: small-caps;
|
||||||
|
font-size: 24pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-score {
|
||||||
|
margin: 2rem auto;
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 36pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-score::after {
|
||||||
|
content: '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grade {
|
||||||
|
margin: 2rem auto;
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 26pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Change Autocomplete styles in Chrome*/
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
textarea:-webkit-autofill,
|
||||||
|
textarea:-webkit-autofill:hover,
|
||||||
|
textarea:-webkit-autofill:focus,
|
||||||
|
select:-webkit-autofill,
|
||||||
|
select:-webkit-autofill:hover,
|
||||||
|
select:-webkit-autofill:focus {
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for Edge
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@supports (-ms-ime-align: auto) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for IE
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
BIN
ref-test/quiz/static/favicon.ico
Normal file
BIN
ref-test/quiz/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
ref-test/quiz/static/favicon.png
Normal file
BIN
ref-test/quiz/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
BIN
ref-test/quiz/static/fonts/opendyslexic3-bold-webfont.woff
Normal file
BIN
ref-test/quiz/static/fonts/opendyslexic3-bold-webfont.woff
Normal file
Binary file not shown.
BIN
ref-test/quiz/static/fonts/opendyslexic3-bold-webfont.woff2
Normal file
BIN
ref-test/quiz/static/fonts/opendyslexic3-bold-webfont.woff2
Normal file
Binary file not shown.
BIN
ref-test/quiz/static/fonts/opendyslexic3-regular-webfont.woff
Normal file
BIN
ref-test/quiz/static/fonts/opendyslexic3-regular-webfont.woff
Normal file
Binary file not shown.
BIN
ref-test/quiz/static/fonts/opendyslexic3-regular-webfont.woff2
Normal file
BIN
ref-test/quiz/static/fonts/opendyslexic3-regular-webfont.woff2
Normal file
Binary file not shown.
BIN
ref-test/quiz/static/fonts/opendyslexicmono-regular-webfont.woff
Normal file
BIN
ref-test/quiz/static/fonts/opendyslexicmono-regular-webfont.woff
Normal file
Binary file not shown.
Binary file not shown.
0
ref-test/quiz/static/fonts/stylesheet.css
Normal file
0
ref-test/quiz/static/fonts/stylesheet.css
Normal file
646
ref-test/quiz/static/js/quiz.js
Normal file
646
ref-test/quiz/static/js/quiz.js
Normal file
@ -0,0 +1,646 @@
|
|||||||
|
// Bind Listeners
|
||||||
|
|
||||||
|
$("input[name='font-select']").change(function(){
|
||||||
|
let $choice = $(this).val();
|
||||||
|
set_font($choice);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("input[name='font-size']").change(function(){
|
||||||
|
let $choice = $(this).val();
|
||||||
|
set_font_size($choice);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("input[name='bg-select']").change(function(){
|
||||||
|
let $choice = $(this).val();
|
||||||
|
set_bg_colour($choice);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn-toggle-navigator").click(function(event){
|
||||||
|
check_answered();
|
||||||
|
update_navigator();
|
||||||
|
if ($quiz_navigator.is(":hidden")) {
|
||||||
|
if ($quiz_settings.is(":visible")) {
|
||||||
|
toggle_settings = true;
|
||||||
|
$quiz_settings.fadeOut();
|
||||||
|
}
|
||||||
|
$quiz_render.fadeOut();
|
||||||
|
$quiz_navigator.fadeIn();
|
||||||
|
$(".navigator-text").fadeIn();
|
||||||
|
$(".review-text").fadeOut();
|
||||||
|
toggle_navigator = false;
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
} else {
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
if (toggle_settings) {
|
||||||
|
$quiz_settings.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
toggle_settings = false;
|
||||||
|
} else {
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn-toggle-settings").click(function(event){
|
||||||
|
if (($quiz_settings).is(":hidden")) {
|
||||||
|
if ($quiz_navigator.is(":visible")) {
|
||||||
|
toggle_navigator = true;
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
}
|
||||||
|
$quiz_render.fadeOut();
|
||||||
|
$quiz_settings.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
toggle_settings = false;
|
||||||
|
} else {
|
||||||
|
$quiz_settings.fadeOut();
|
||||||
|
if (toggle_navigator) {
|
||||||
|
$quiz_navigator.fadeIn();
|
||||||
|
toggle_navigator = false;
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
} else {
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".btn-quiz-return").click(function(event){
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
$quiz_settings.fadeOut();
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
toggle_settings = false;
|
||||||
|
toggle_navigator = false;
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".btn-dummy").click(function(event){
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#navigator-container").on("click", ".q-navigator-button", function(event){
|
||||||
|
check_answered();
|
||||||
|
update_navigator();
|
||||||
|
current_question = parseInt($(this).attr("name"));
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$question_title.focus();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
toggle_navigator = false;
|
||||||
|
toggle_settings = false;
|
||||||
|
render_question();
|
||||||
|
check_flag();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".q-question-nav").click(function(event){
|
||||||
|
check_answered();
|
||||||
|
update_navigator();
|
||||||
|
if ($(this).attr("id") == "q-nav-next") {
|
||||||
|
if (current_question < questions.length) {
|
||||||
|
current_question ++;
|
||||||
|
}
|
||||||
|
} else if ($(this).attr("id") == "q-nav-prev") {
|
||||||
|
if (current_question > 0) {
|
||||||
|
current_question --;
|
||||||
|
}
|
||||||
|
} else if ($(this).hasClass("q-navigator-button")) {
|
||||||
|
current_question = $(this).attr("name");
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
toggle_navigator = false;
|
||||||
|
toggle_settings = false;
|
||||||
|
}
|
||||||
|
render_question();
|
||||||
|
check_flag();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#q-nav-flag").click(function(event){
|
||||||
|
if (question_status[current_question] != 1) {
|
||||||
|
question_status[current_question] = 1;
|
||||||
|
$(this).removeClass().addClass("btn btn-warning");
|
||||||
|
$(this).attr("title", "Question Flagged for revision. Click to un-flag.");
|
||||||
|
} else {
|
||||||
|
question_status[current_question] = 0;
|
||||||
|
$(this).removeClass().addClass("btn btn-secondary");
|
||||||
|
$(this).attr("title", "Question Un-Flagged. Click to flag for revision.");
|
||||||
|
}
|
||||||
|
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||||
|
update_navigator();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn-start-quiz").click(function(event){
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: `/api/questions/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'_id': _id}),
|
||||||
|
contentType: "application/json",
|
||||||
|
success: function(response) {
|
||||||
|
$(this).fadeOut();
|
||||||
|
$(".btn-quiz-return").fadeIn();
|
||||||
|
$(".quiz-console").fadeIn();
|
||||||
|
$("#quiz-settings").fadeOut();
|
||||||
|
$("#quiz-navigator").fadeOut();
|
||||||
|
$(".quiz-start-text").fadeOut();
|
||||||
|
time_limit = response.time_limit;
|
||||||
|
start_time = response.start_time;
|
||||||
|
questions = response.questions;
|
||||||
|
total_questions = questions.length;
|
||||||
|
window.localStorage.setItem('questions', JSON.stringify(questions));
|
||||||
|
window.localStorage.setItem('start_time', JSON.stringify(start_time));
|
||||||
|
window.localStorage.setItem('time_limit', JSON.stringify(time_limit));
|
||||||
|
render_question();
|
||||||
|
build_navigator();
|
||||||
|
check_flag();
|
||||||
|
if (time_limit != 'null' && time_limit != null) {
|
||||||
|
$("#q-timer-widget").fadeIn();
|
||||||
|
time_remaining = get_time_remaining();
|
||||||
|
clock = setInterval(timer, 1000);
|
||||||
|
}
|
||||||
|
if (response.time_adjustment > 0) {
|
||||||
|
const $alert = $("#alert-box");
|
||||||
|
$alert.html(
|
||||||
|
`<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Alert"></i>
|
||||||
|
User code validated. Extra time of ${response.time_adjustment} minutes added to the exam time limit.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
$alert.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#quiz-question-options").on("change", ".quiz-option", function(event){
|
||||||
|
$name = parseInt($(this).attr("name"));
|
||||||
|
$value = $(this).attr("value");
|
||||||
|
answers[$name] = $value;
|
||||||
|
window.localStorage.setItem('answers', JSON.stringify(answers));
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#q-review-answers").click(function(event){
|
||||||
|
check_answered();
|
||||||
|
update_navigator();
|
||||||
|
if ($quiz_navigator.is(":hidden")) {
|
||||||
|
if ($quiz_settings.is(":visible")) {
|
||||||
|
toggle_settings = true;
|
||||||
|
$quiz_settings.fadeOut();
|
||||||
|
}
|
||||||
|
$quiz_render.fadeOut();
|
||||||
|
$quiz_navigator.fadeIn();
|
||||||
|
$(".navigator-text").fadeOut();
|
||||||
|
$(".review-text").fadeIn();
|
||||||
|
toggle_navigator = false;
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
} else {
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
if (toggle_settings) {
|
||||||
|
$quiz_settings.fadeIn();
|
||||||
|
toggle_settings = false;
|
||||||
|
} else {
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".quiz-button-submit").click(function(event){
|
||||||
|
let submission = {
|
||||||
|
'_id': _id,
|
||||||
|
'answers': answers
|
||||||
|
}
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: `/api/submit/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify(submission),
|
||||||
|
contentType: "application/json",
|
||||||
|
success: function(response) {
|
||||||
|
window.localStorage.clear();
|
||||||
|
window.location.href = `/result/`;
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
|
||||||
|
function set_font(value = 'osdefault') {
|
||||||
|
let font_styles = ['arial', 'comicsans', 'opendyslexic', 'tahoma', 'verdana']
|
||||||
|
|
||||||
|
for (let i = 0; i < font_styles.length; i ++) {
|
||||||
|
if (font_styles[i] != value) {
|
||||||
|
$("body").removeClass( `q-f-${font_styles[i]}` );
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value != 'osdefault') {
|
||||||
|
$("body").addClass(`q-f-${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
display_settings['font-select'] = value;
|
||||||
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
||||||
|
$('input[name="font-select"][value="' + value + '"]').prop('checked', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_font_size(value = '14pt') {
|
||||||
|
let font_sizes = ['12pt', '16pt', '18pt']
|
||||||
|
|
||||||
|
for (let i = 0; i < font_sizes.length; i ++) {
|
||||||
|
if (font_sizes[i] != value) {
|
||||||
|
$("body").removeClass( `q-f-${font_sizes[i]}` );
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value != '14pt') {
|
||||||
|
$("body").addClass(`q-f-${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
display_settings['font-size'] = value;
|
||||||
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
||||||
|
$('input[name="font-size"][value="' + value + '"]').prop('checked', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_bg_colour(value = 'bg-light') {
|
||||||
|
let backgrounds = ['bg-light', 'q-bg-light-1', 'q-bg-light-2', 'alert-primary', 'alert-secondary', 'alert-dark', 'bg-dark']
|
||||||
|
|
||||||
|
for (let i = 0; i < backgrounds.length; i ++) {
|
||||||
|
if (backgrounds[i] != value) {
|
||||||
|
$("body").removeClass(backgrounds[i]);
|
||||||
|
if (backgrounds[i] == 'bg-dark') {
|
||||||
|
$("body").removeClass('text-light');
|
||||||
|
};
|
||||||
|
if (backgrounds[i] == 'alert-primary' || backgrounds[i] == 'alert-secondary' || backgrounds[i] == 'alert-dark') {
|
||||||
|
$("body").removeClass('text-dark');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$("body").addClass(value);
|
||||||
|
if (value == 'bg-dark') {
|
||||||
|
$("body").addClass('text-light');
|
||||||
|
};
|
||||||
|
if (value == 'alert-primary' || value == 'alert-secondary' || value == 'alert-dark') {
|
||||||
|
$("body").addClass('text-dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
display_settings['bg-select'] = value;
|
||||||
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
||||||
|
$('input[name="bg-select"][value="' + value + '"]').prop('checked', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_settings_from_storage() {
|
||||||
|
let display_settings = window.localStorage.getItem('display_settings')
|
||||||
|
if (display_settings != null) {
|
||||||
|
return JSON.parse(display_settings);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
'font-select': 'osdefault',
|
||||||
|
'font-size': '14pt',
|
||||||
|
'bg-select': 'bg-light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply_settings(settings) {
|
||||||
|
set_font(settings['font-select']);
|
||||||
|
set_font_size(settings['font-size']);
|
||||||
|
set_bg_colour(settings['bg-select']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_question() {
|
||||||
|
if (current_question == 0) {
|
||||||
|
$nav_prev.addClass('disabled');
|
||||||
|
}
|
||||||
|
if (current_question == questions.length - 1) {
|
||||||
|
$nav_next.addClass('disabled');
|
||||||
|
}
|
||||||
|
if ($nav_prev.hasClass('disabled') && current_question > 0) {
|
||||||
|
$nav_prev.removeClass('disabled');
|
||||||
|
}
|
||||||
|
if ($nav_next.hasClass('disabled') && current_question < questions.length - 1) {
|
||||||
|
$nav_next.removeClass('disabled');
|
||||||
|
}
|
||||||
|
var question = questions[current_question];
|
||||||
|
let header_text = question.question_header;
|
||||||
|
var block_length = 0;
|
||||||
|
if ('block_length' in question) {
|
||||||
|
block_length = question['block_length'];
|
||||||
|
};
|
||||||
|
var block_q_no = 0;
|
||||||
|
if ('block_q_no' in question) {
|
||||||
|
block_q_no = question['block_q_no'];
|
||||||
|
}
|
||||||
|
let remaining_qs = (block_length - block_q_no).toString();
|
||||||
|
if (block_length - block_q_no > 1) {
|
||||||
|
remaining_qs += ' questions';
|
||||||
|
} else {
|
||||||
|
remaining_qs += ' question';
|
||||||
|
}
|
||||||
|
header_text = header_text.replace('<block_remaining_questions>', remaining_qs);
|
||||||
|
$question_header.html(header_text);
|
||||||
|
$question_text.html(question.text);
|
||||||
|
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`);
|
||||||
|
|
||||||
|
var q_no = question['q_no'];
|
||||||
|
var options = question.options;
|
||||||
|
var options_output = '';
|
||||||
|
for (let i = 0; i < options.length; i ++) {
|
||||||
|
var add_checked = ''
|
||||||
|
if (q_no in answers) {
|
||||||
|
if (answers[q_no] == options[i]) {
|
||||||
|
add_checked = 'checked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options_output += `<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i]}" ${add_checked}>
|
||||||
|
<label for="q${current_question}-${i}" class="form-check-label">${options[i]}</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
$question_options.html(options_output);
|
||||||
|
let skipped = count_questions(-1);
|
||||||
|
let answered = count_questions(2);
|
||||||
|
let flagged = count_questions(1);
|
||||||
|
|
||||||
|
$progress_skipped.attr('title', `Skipped: ${skipped}`);
|
||||||
|
$progress_skipped.attr('aria-valuenow', skipped);
|
||||||
|
$progress_skipped.css('width', `${skipped}%`);
|
||||||
|
$skipped_count.text(`Skipped: ${skipped}`);
|
||||||
|
if (skipped < 1) {
|
||||||
|
$skipped_count.fadeOut()
|
||||||
|
} else {
|
||||||
|
$skipped_count.fadeIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress_flagged.attr('title', `Flagged: ${flagged}`);
|
||||||
|
$progress_flagged.attr('aria-valuenow', flagged);
|
||||||
|
$progress_flagged.css('width', `${flagged}%`);
|
||||||
|
$flagged_count.text(`Flagged: ${flagged}`);
|
||||||
|
if (flagged < 1) {
|
||||||
|
$flagged_count.fadeOut()
|
||||||
|
} else {
|
||||||
|
$flagged_count.fadeIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress_answered.attr('title', `Answered: ${answered}`);
|
||||||
|
$progress_answered.attr('aria-valuenow', answered);
|
||||||
|
$progress_answered.css('width', `${answered}%`);
|
||||||
|
$answered_count.text(`Answered: ${answered}`);
|
||||||
|
if (answered < 1) {
|
||||||
|
$answered_count.fadeOut()
|
||||||
|
} else {
|
||||||
|
$answered_count.fadeIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
$question_title.focus();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_answered() {
|
||||||
|
var question = questions[current_question];
|
||||||
|
var name = question.q_no;
|
||||||
|
if (question_status[current_question] == 0 || question_status[current_question] == -1) {
|
||||||
|
if (!$(`input[name='${name}']:checked`).val()) {
|
||||||
|
question_status[current_question] = -1;
|
||||||
|
} else {
|
||||||
|
question_status[current_question] = 2;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_flag() {
|
||||||
|
if (!(current_question in question_status)) {
|
||||||
|
question_status[current_question] = 0;
|
||||||
|
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||||
|
}
|
||||||
|
switch (question_status[current_question]) {
|
||||||
|
case -1:
|
||||||
|
$nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped');
|
||||||
|
$nav_flag.attr("title", "Question Incomplete. Click to flag for revision.");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
$nav_flag.removeClass().addClass('btn btn-warning');
|
||||||
|
$nav_flag.attr("title", "Question Flagged for revision. Click to un-flag.");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
$nav_flag.removeClass().addClass('btn btn-success');
|
||||||
|
$nav_flag.attr("title", "Question Answered. Click to flag for revision.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$nav_flag.removeClass().addClass('btn btn-secondary');
|
||||||
|
$nav_flag.attr("title", "Question Un-Flagged. Click to flag for revision.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_navigator() {
|
||||||
|
$nav_container.html('')
|
||||||
|
var output = ''
|
||||||
|
for (let i = 0; i < questions.length; i ++) {
|
||||||
|
let add_class, add_href, add_status = '';
|
||||||
|
switch (question_status[i]) {
|
||||||
|
case -1:
|
||||||
|
add_class = 'btn-danger progress-bar-striped';
|
||||||
|
add_href = 'href="#"';
|
||||||
|
add_status = 'Incomplete';
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
add_class = 'btn-warning';
|
||||||
|
add_href = 'href="#"';
|
||||||
|
add_status = 'Flagged';
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
add_class = 'btn-success';
|
||||||
|
add_href = 'href="#"';
|
||||||
|
add_status = 'Answered';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
add_class = 'btn-secondary disabled';
|
||||||
|
add_href = '';
|
||||||
|
add_status = 'Unseen';
|
||||||
|
}
|
||||||
|
output += `<a ${add_href} class="q-navigator-button btn ${add_class}" name=${i} title="Question ${i+1}: ${add_status}">Q${i + 1}</a>`;
|
||||||
|
}
|
||||||
|
$nav_container.html(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_navigator() {
|
||||||
|
let button = $(`.q-navigator-button[name=${current_question}]`)
|
||||||
|
if (current_question in question_status) {
|
||||||
|
switch (question_status[current_question]) {
|
||||||
|
case -1:
|
||||||
|
button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped");
|
||||||
|
button.attr("title", `Question ${current_question + 1}: Incomplete`);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
button.removeClass().addClass("q-navigator-button btn btn-warning");
|
||||||
|
button.attr("title", `Question ${current_question + 1}: Flagged`);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
button.removeClass().addClass("q-navigator-button btn btn-success");
|
||||||
|
button.attr("title", `Question ${current_question + 1}: Answered`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled");
|
||||||
|
button.attr("title", `Question ${current_question + 1}: Unseen`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
$("#btn-start-quiz").fadeOut();
|
||||||
|
$(".btn-quiz-return").fadeIn();
|
||||||
|
$(".quiz-console").fadeIn();
|
||||||
|
$("#quiz-settings").fadeOut();
|
||||||
|
$("#quiz-navigator").fadeOut();
|
||||||
|
$(".quiz-start-text").fadeOut();
|
||||||
|
|
||||||
|
questions = JSON.parse(window.localStorage.getItem('questions'));
|
||||||
|
total_questions = questions.length;
|
||||||
|
start_time = window.localStorage.getItem('start_time');
|
||||||
|
time_limit = window.localStorage.getItem('time_limit');
|
||||||
|
|
||||||
|
let get_answers = window.localStorage.getItem('answers');
|
||||||
|
if (get_answers != null) {
|
||||||
|
answers = JSON.parse(get_answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
let get_status = window.localStorage.getItem('question_status');
|
||||||
|
if (get_status != null) {
|
||||||
|
question_status = JSON.parse(get_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
render_question();
|
||||||
|
build_navigator();
|
||||||
|
check_flag();
|
||||||
|
if (time_limit != 'null' && time_limit != null) {
|
||||||
|
$("#q-timer-widget").fadeIn();
|
||||||
|
time_remaining = get_time_remaining();
|
||||||
|
clock = setInterval(timer, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_started() {
|
||||||
|
let questions = window.localStorage.getItem('questions');
|
||||||
|
let time_limit = window.localStorage.getItem('time_limit');
|
||||||
|
let start_time = window.localStorage.getItem('start_time')
|
||||||
|
if (questions != null && start_time != null && time_limit != null) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_time_remaining() {
|
||||||
|
var end_time = new Date(time_limit).getTime();
|
||||||
|
var _start_time = new Date().getTime();
|
||||||
|
return end_time - _start_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timer() {
|
||||||
|
var hours = Math.floor((time_remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
var minutes = Math.floor((time_remaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
var seconds = Math.floor((time_remaining % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
if (time_remaining > 0) {
|
||||||
|
var timer_display = '';
|
||||||
|
if (hours > 0) {
|
||||||
|
timer_display = `${hours.toString()}:`;
|
||||||
|
}
|
||||||
|
if (minutes > 0 || hours > 0) {
|
||||||
|
if (minutes < 10) {
|
||||||
|
timer_display += `0${minutes.toString()}:`;
|
||||||
|
} else {
|
||||||
|
timer_display += `${minutes.toString()}:`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seconds < 10) {
|
||||||
|
timer_display += `0${seconds.toString()}`;
|
||||||
|
} else {
|
||||||
|
timer_display += seconds.toString();
|
||||||
|
}
|
||||||
|
$timer.html(timer_display);
|
||||||
|
time_remaining -= 1000
|
||||||
|
} else {
|
||||||
|
$timer.html('Expired');
|
||||||
|
clearInterval(clock);
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
$quiz_render.fadeOut();
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
$quiz_timeout.fadeIn();
|
||||||
|
$("#btn-toggle-navigator").addClass('disabled');
|
||||||
|
$("#btn-toggle-settings").addClass('disabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
function count_questions(status) {
|
||||||
|
output = 0;
|
||||||
|
for (let i = 0; i < Object.keys(question_status).length; i++) {
|
||||||
|
key = Object.keys(question_status)[i];
|
||||||
|
if (question_status[key] == status){
|
||||||
|
output ++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable Definitions
|
||||||
|
|
||||||
|
const _id = window.localStorage.getItem('_id');
|
||||||
|
|
||||||
|
var current_question = 0;
|
||||||
|
var total_questions = 0;
|
||||||
|
var question_status = {};
|
||||||
|
var answers = {};
|
||||||
|
var questions = [];
|
||||||
|
var time_limit, start_time, time_remaining;
|
||||||
|
|
||||||
|
var display_settings = get_settings_from_storage();
|
||||||
|
|
||||||
|
const $quiz_settings = $("#quiz-settings");
|
||||||
|
const $quiz_navigator = $("#quiz-navigator");
|
||||||
|
const $quiz_render = $("#quiz-render");
|
||||||
|
const $quiz_timeout = $("#quiz-timeout");
|
||||||
|
const $nav_flag = $("#q-nav-flag");
|
||||||
|
const $nav_next = $("#q-nav-next");
|
||||||
|
const $nav_prev = $("#q-nav-prev");
|
||||||
|
const $nav_container = $("#navigator-container");
|
||||||
|
const $timer = $("#q-timer-display");
|
||||||
|
var clock
|
||||||
|
|
||||||
|
var toggle_settings = false;
|
||||||
|
var toggle_navigator = false;
|
||||||
|
|
||||||
|
const $question_title = $("#quiz-question-title");
|
||||||
|
const $question_header = $("#quiz-question-header");
|
||||||
|
const $question_text = $("#quiz-question-text");
|
||||||
|
const $question_options = $("#quiz-question-options");
|
||||||
|
|
||||||
|
const $progress_skipped = $("#skipped-bar");
|
||||||
|
const $progress_answered = $("#answered-bar");
|
||||||
|
const $progress_flagged = $("#flagged-bar");
|
||||||
|
const $skipped_count = $("#skipped-count");
|
||||||
|
const $answered_count = $("#answered-count");
|
||||||
|
const $flagged_count = $("#flagged-count");
|
||||||
|
|
||||||
|
// Execution on Load
|
||||||
|
|
||||||
|
apply_settings(display_settings);
|
||||||
|
check_started();
|
86
ref-test/quiz/static/js/script.js
Normal file
86
ref-test/quiz/static/js/script.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
$(document).ready(function() {
|
||||||
|
$("#od-font-test").click(function(){
|
||||||
|
$("body").css("font-family", "opendyslexic3regular")
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.test-code-input').keyup(function() {
|
||||||
|
var input = $(this).val().split("-").join("").split("—").join("");
|
||||||
|
if (input.length > 0) {
|
||||||
|
input = input.match(new RegExp('.{1,4}', 'g')).join("—");
|
||||||
|
}
|
||||||
|
$(this).val(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('form[name=form-quiz-start]').submit(function(event) {
|
||||||
|
|
||||||
|
var $form = $(this);
|
||||||
|
var data = $form.serialize();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: window.location.pathname,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response) {
|
||||||
|
var _id = response._id
|
||||||
|
window.localStorage.setItem('_id', _id);
|
||||||
|
window.location.href = `/test/`;
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
function error_response(response) {
|
||||||
|
|
||||||
|
const $alert = $("#alert-box");
|
||||||
|
$alert.html('');
|
||||||
|
|
||||||
|
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||||
|
$alert.html(`
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
|
var output = ''
|
||||||
|
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
|
output += `
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error[i]}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
$alert.html(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss Cookie Alert
|
||||||
|
$('#dismiss-cookie-alert').click(function(event){
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/cookies/',
|
||||||
|
type: 'GET',
|
||||||
|
data: {
|
||||||
|
time: Date.now()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
console.log(response);
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
console.log(response);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
})
|
8
ref-test/quiz/templates/quiz/404.html
Normal file
8
ref-test/quiz/templates/quiz/404.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{% extends "quiz/components/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Page Not Found</h1>
|
||||||
|
<p>
|
||||||
|
The page you were looking for does not exist. Try going back and navigating to the desired destination correctly.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
279
ref-test/quiz/templates/quiz/client.html
Normal file
279
ref-test/quiz/templates/quiz/client.html
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
{% extends "quiz/components/base.html" %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/quiz.css') }}"
|
||||||
|
/>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="alert-box" tabindex="-1"></div>
|
||||||
|
<div class="container quiz-panel" id="quiz-settings" tabindex="-1">
|
||||||
|
<h1>Adjust Display Settings</h1>
|
||||||
|
<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.
|
||||||
|
</div>
|
||||||
|
<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.
|
||||||
|
</div>
|
||||||
|
<form action="#" name="quiz-configuration">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row gx-5 gy-5">
|
||||||
|
<div class="col">
|
||||||
|
<h5>
|
||||||
|
Select Font
|
||||||
|
</h5>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="osdefault" name="font-select" value="osdefault" checked>
|
||||||
|
<label for="osdefault" class="form-check-label q-f-osdefault">OS Default</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="arial" name="font-select" value="arial">
|
||||||
|
<label for="arial" class="form-check-label q-f-arial">Arial</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="comicsans" name="font-select" value="comicsans">
|
||||||
|
<label for="comicsans" class="form-check-label q-f-comicsans">Comic Sans</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="opendyslexic" name="font-select" value="opendyslexic">
|
||||||
|
<label for="opendyslexic" class="form-check-label q-f-opendyslexic">OpenDyslexic</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="tahoma" name="font-select" value="tahoma">
|
||||||
|
<label for="tahoma" class="form-check-label q-f-tahoma">Tahoma</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="verdana" name="font-select" value="verdana">
|
||||||
|
<label for="verdana" class="form-check-label q-f-verdana">Verdana</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<h5>
|
||||||
|
Select Font Size
|
||||||
|
</h5>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="12pt" name="font-size" value="12pt" checked>
|
||||||
|
<label for="12pt" class="form-check-label q-f-12pt">12pt</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="14pt" name="font-size" value="14pt" checked>
|
||||||
|
<label for="14pt" class="form-check-label q-f-14pt">14pt (Default)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="16pt" name="font-size" value="16pt">
|
||||||
|
<label for="16pt" class="form-check-label q-f-16pt">16pt</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="18pt" name="font-size" value="18pt">
|
||||||
|
<label for="18pt" class="form-check-label q-f-18pt">18pt</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row gx-5 gy-5 mt-1">
|
||||||
|
<div class="col">
|
||||||
|
<h5>Select Background Colour</h5>
|
||||||
|
<div class="p-3 bg-light text-dark">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="bg-light" name="bg-select" value="bg-light" checked>
|
||||||
|
<label for="bg-light" class="form-check-label">Default Light</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 q-bg-light-1 text-dark">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="q-bg-light-1" name="bg-select" value="q-bg-light-1">
|
||||||
|
<label for="q-bg-light-1" class="form-check-label">Light Shade 1</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 q-bg-light-2 text-dark">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="q-bg-light-2" name="bg-select" value="q-bg-light-2">
|
||||||
|
<label for="q-bg-light-2" class="form-check-label">Light Shade 2</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 alert-primary text-dark">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="alert-primary" name="bg-select" value="alert-primary">
|
||||||
|
<label for="alert-primary" class="form-check-label">Blue</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 alert-secondary text-dark">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="alert-secondary" name="bg-select" value="alert-secondary">
|
||||||
|
<label for="alert-secondary" class="form-check-label">Grey 1</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 alert-dark text-dark">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="alert-dark" name="bg-select" value="alert-dark">
|
||||||
|
<label for="alert-dark" class="form-check-label">Grey 2</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-dark text-light">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="bg-dark" name="bg-select" value="bg-dark">
|
||||||
|
<label for="bg-dark" class="form-check-label">Dark</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="container question-container quiz-start-text">
|
||||||
|
<h4 class="question-title">Sample Question</h4>
|
||||||
|
<p class="question-header">
|
||||||
|
Korfball is a mixed-sex, controlled-contact, indoor, invasion ball sport. The sport originated in the Netherlands. It is a mixed-sex team sport. Its governing body is the International Korball Federation. There are numerous korfball leagues and associations around the world. A korfball match is officiated by a referee.
|
||||||
|
</p>
|
||||||
|
<p class="question-text">
|
||||||
|
In order to be a referee, what do you need to know?
|
||||||
|
</p>
|
||||||
|
<div class="options">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample0" name="sample" value="0">
|
||||||
|
<label for="sample0" class="form-check-label">The rules of korfball</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample1" name="sample" value="1">
|
||||||
|
<label for="sample1" class="form-check-label">The way of the Jedi Order</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample2" name="sample" value="2">
|
||||||
|
<label for="sample2" class="form-check-label">The <i>Dungeons & Dragons Fifth Edition Monster Manual</i>.</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample3" name="sample" value="2">
|
||||||
|
<label for="sample3" class="form-check-label">The Trade Union and Labour Relations (Consolidation) Act 1992.</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample4" name="sample" value="4" checked>
|
||||||
|
<label for="sample4" class="form-check-label">All of the above</i></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<p class="quiz-start-text">
|
||||||
|
When you are happy with the settings, click <strong>‘Start the Exam’</strong> below to proceed. You can change these settings at any time using the red gear <a class="btn btn-danger btn-dummy" tabindex="-1" aria-title="Settings" title="Settings"><i class="bi bi-gear-fill"></i></a> button on the exam console.
|
||||||
|
</p>
|
||||||
|
<div class="control-button-container">
|
||||||
|
<a href="#" class="btn btn-success btn-quiz-control" id="btn-start-quiz">Start the Exam</a>
|
||||||
|
<a href="#" class="btn btn-success btn-quiz-control btn-quiz-return" style="display: none;">Resume Exam</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container quiz-panel" style="display: none;" id="quiz-navigator" tabindex="-1">
|
||||||
|
<h1 class="navigator-text">
|
||||||
|
Question Grid
|
||||||
|
</h1>
|
||||||
|
<h1 class="review-text" style="display: none;">
|
||||||
|
Review Your Answers
|
||||||
|
</h1>
|
||||||
|
<div class="navigator-text">
|
||||||
|
This question grid displays the progress you have on the exam so far. Each question is represented by an icon below, and you can click on each icon to skip to that question.
|
||||||
|
|
||||||
|
The icons below are colour-coded to represent the status of each question.
|
||||||
|
</div>
|
||||||
|
<div class="review-text" style="display: none;">
|
||||||
|
You can use this panel to review your answers before you submit the exam. You will not be able to amend your answers after you submit.
|
||||||
|
|
||||||
|
Each question is represented by an icon below, and you can click on each icon to skip to that question. The icons below are colour-coded to represent the status of each question.
|
||||||
|
</div>
|
||||||
|
<table class="navigator-help">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="q-navigator-button btn btn-danger progress-bar-striped btn-dummy" title="Question: Incomplete">Q</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
A red and striped icon represents a question that you have skipped, and have not otherwise flagged for revision.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="q-navigator-button btn btn-warning btn-dummy" title="Question: Flagged">Q</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
A yellow icon represents a question that you have flagged for revision.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="q-navigator-button btn btn-success btn-dummy" title="Question: Answered">Q</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
A green icon represents a question that you have answered, and have not otherwise flagged for revision.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="q-navigator-button btn btn-secondary disabled btn-dummy" title="Question: Unseen">Q</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
A greyed-out icon represents a question that you have not yet seen.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div id="navigator-container">
|
||||||
|
</div>
|
||||||
|
<div class="control-button-container navigator-text">
|
||||||
|
<a href="#" class="btn btn-success btn-quiz-control btn-quiz-return">Resume Exam</a>
|
||||||
|
</div>
|
||||||
|
<div class="control-button-container review-text">
|
||||||
|
<a href="#" class="btn btn-danger btn-quiz-control btn-quiz-return">Back to Exam</a>
|
||||||
|
<a href="#" class="btn btn-success quiz-button-submit">Submit Exam</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container quiz-panel quiz-console" style="display: none" id="quiz-render">
|
||||||
|
<h1>
|
||||||
|
Exam Console
|
||||||
|
</h1>
|
||||||
|
<div class="container question-container">
|
||||||
|
<div class="progress">
|
||||||
|
<div id="answered-bar" class="progress-bar bg-success" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
<div id="flagged-bar" class="progress-bar bg-warning" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
<div id="skipped-bar" class="progress-bar progress-bar-striped bg-danger" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<div class="counters">
|
||||||
|
<div id="answered-count" class="badge rounded-pill bg-success" style="display: none;">Answered: 0</div>
|
||||||
|
<div id="flagged-count" class="badge rounded-pill bg-warning"style="display: none;">Flagged: 0</div>
|
||||||
|
<div id="skipped-count" class="badge rounded-pill bg-danger progress-bar-striped"style="display: none;">Skipped: 0</div>
|
||||||
|
</div>
|
||||||
|
<h4 class="question-title" id="quiz-question-title" tabindex="-1">
|
||||||
|
Question x.
|
||||||
|
</h4>
|
||||||
|
<p class="question-header" id="quiz-question-header">
|
||||||
|
Question Header
|
||||||
|
</p>
|
||||||
|
<p class="question-text" id="quiz-question-text">
|
||||||
|
Question Text
|
||||||
|
</p>
|
||||||
|
<div class="options" id="quiz-question-options">
|
||||||
|
Options
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-button-container">
|
||||||
|
<a href="#" class="btn btn-success q-question-nav" id="q-nav-prev" title="Previous Question"><i class="bi bi-caret-left-square-fill"></i> Back</a>
|
||||||
|
<a href="#" class="btn btn-secondary" id="q-nav-flag" title="Question Un-Flagged. Click to flag for revision."><i class="bi bi-flag-fill"></i></a>
|
||||||
|
<a href="#" class="btn btn-success q-question-nav" id="q-nav-next" title="Next Question">Next <i class="bi bi-caret-right-square-fill"></i></a>
|
||||||
|
</div>
|
||||||
|
<div class="control-button-container">
|
||||||
|
<a href="#" class="btn btn-primary" id="q-review-answers" title="Submit Answers">Submit Answers</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container quiz-panel quiz-timeout" style="display: none;" id="quiz-timeout">
|
||||||
|
<h1>
|
||||||
|
Time Limit Expired
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
The time limit set for this exam has expired. You must submit your answers immediately.
|
||||||
|
</p>
|
||||||
|
<div class="control-button-container">
|
||||||
|
<a href="#" class="btn btn-success quiz-button-submit" title="Submit Exam">Submit Exam</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/quiz.js') }}"
|
||||||
|
></script>
|
||||||
|
{% endblock %}
|
75
ref-test/quiz/templates/quiz/components/base.html
Normal file
75
ref-test/quiz/templates/quiz/components/base.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<!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.0.2/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||||
|
/>
|
||||||
|
{% block style %}
|
||||||
|
{% endblock %}
|
||||||
|
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
||||||
|
{% include "quiz/components/og-meta.html" %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
{% block navbar %}
|
||||||
|
{% include "quiz/components/navbar.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<div class="container quiz-container">
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% include "quiz/components/server-alerts.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
<footer class="container site-footer">
|
||||||
|
{% include "quiz/components/footer.html" %}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
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>
|
3
ref-test/quiz/templates/quiz/components/footer.html
Normal file
3
ref-test/quiz/templates/quiz/components/footer.html
Normal file
@ -0,0 +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>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>
|
14
ref-test/quiz/templates/quiz/components/navbar.html
Normal file
14
ref-test/quiz/templates/quiz/components/navbar.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
||||||
|
<div class="container">
|
||||||
|
<p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p>
|
||||||
|
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
|
||||||
|
<div class="d-flex justify-content align-middle">
|
||||||
|
<div class="container d-flex justify-content-center">
|
||||||
|
<span class="text-light q-timer" id="q-timer-widget" style="display: none;"><i class="bi bi-stopwatch-fill"></i> <span id="q-timer-display"></span></span>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="btn btn-warning" aria-title="Question Grid" title="Question Grid" id="btn-toggle-navigator"><i class="bi bi-table"></i></a>
|
||||||
|
<a href="#" class="btn btn-danger" aria-title="Settings" title="Settings" id="btn-toggle-settings"><i class="bi bi-gear-fill"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
17
ref-test/quiz/templates/quiz/components/og-meta.html
Normal file
17
ref-test/quiz/templates/quiz/components/og-meta.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<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) }}" />
|
||||||
|
<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" />
|
43
ref-test/quiz/templates/quiz/components/server-alerts.html
Normal file
43
ref-test/quiz/templates/quiz/components/server-alerts.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% set cookie_flash_flag = namespace(value=False) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
{% if category == "error" %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "success" %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "warning" %}
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif category == "cookie_alert" %}
|
||||||
|
{% if not cookie_flash_flag.value %}
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<div class="d-flex justify-content-center w-100">
|
||||||
|
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set cookie_flash_flag.value = True %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||||
|
{{ message|safe }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
21
ref-test/quiz/templates/quiz/index.html
Normal file
21
ref-test/quiz/templates/quiz/index.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{% extends "quiz/components/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>SKA Refereeing Theory Exam</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This app will enable you to take the SKA Refereeing Exam on-line. The app will further allow you to change the display settings — such as the font size, typeface, and background colour — to a layout that you may find more suitable.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We designed this app to prioritise accessibility for the exam, and to ensure that it could be presented in a manner that does not put people with any specific needs at a disadvantage.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The presentation of the questions is just a start, and we acknowledge we still have a long way to go. We welcome any feedback on how we can further improve this test.
|
||||||
|
</p>
|
||||||
|
<div class="button-container">
|
||||||
|
<a href="{{ url_for('quiz_views.instructions') }}" class="btn btn-success">
|
||||||
|
<i class="bi bi-book-fill button-icon"></i>
|
||||||
|
Read the Instructions
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
61
ref-test/quiz/templates/quiz/instructions.html
Normal file
61
ref-test/quiz/templates/quiz/instructions.html
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{% extends "quiz/components/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="instruction-container">
|
||||||
|
<h3>Instructions</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The exam comprises 100 multiple-choice questions.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
For each question, answer what decision you would give as a referee unless the question instructs otherwise.
|
||||||
|
</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>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
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>
|
||||||
|
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>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="instruction-container">
|
||||||
|
<h3>
|
||||||
|
Technical Details
|
||||||
|
</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
To ensure compatibility, make sure you use the latest version of Firefox, Chrome, Safari, or other fairly modern browser. Make sure JavaScript is enabled.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Once you start the exam, your answers are stored locally on your browser until you submit your final results to the server.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Do not close the window, refresh the page, or navigate to a different page as you could risk losing your progress.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>If you have any technical issues while taking the exam, please report them immediately to <a href="mailto:refereeing@scotlandkorfball.co.uk">refereeing@scotlandkorfball.co.uk</a></strong>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instruction-container">
|
||||||
|
<h4>
|
||||||
|
Results
|
||||||
|
</h4>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When you are ready to begin the quiz, click the following button.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-container">
|
||||||
|
<a href="{{ url_for('quiz_views.start') }}" class="btn btn-success">
|
||||||
|
<i class="bi bi-pencil-fill button-icon"></i>
|
||||||
|
Take the Exam
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
26
ref-test/quiz/templates/quiz/privacy.html
Normal file
26
ref-test/quiz/templates/quiz/privacy.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{% extends "quiz/components/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
|
||||||
|
This web app stores data using cookies. The web site only stores the minimum information it needs to function.
|
||||||
|
<h5>Site Administrators</h5>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h5>Test Candidates</h5>
|
||||||
|
<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>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>All identifying information about candidates will be encrypted and stored in a secure database.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h5>Requests to Delete Data</h5>
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
44
ref-test/quiz/templates/quiz/result.html
Normal file
44
ref-test/quiz/templates/quiz/result.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{% extends "quiz/components/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>SKA Refereeing Theory Exam</h1>
|
||||||
|
|
||||||
|
<h2>Candidate Results</h2>
|
||||||
|
|
||||||
|
<h3 class="results-name">
|
||||||
|
<span class="surname">{{ entry.name.surname }}</span>, {{ entry.name.first_name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<strong class="results-details">Email Address</strong>: {{ entry.email }} <br />
|
||||||
|
|
||||||
|
{% if entry.club %}
|
||||||
|
<strong class="results-details">Club</strong>: {{ entry.club }} <br />
|
||||||
|
{% endif%}
|
||||||
|
|
||||||
|
{% if entry.status == 'late' %}
|
||||||
|
|
||||||
|
Your results are invalid because you did not submit your exam in time. Please contact the SKA Refereeing Coordinator.
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="results-score">
|
||||||
|
{{ score }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-grade">
|
||||||
|
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if entry.results.grade == 'fail' %}
|
||||||
|
Unfortunately, you have not passed the theory exam in this attempt. For your next attempt, it might help to revise the following topics:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for tag in tag_output %}
|
||||||
|
<li>{{ tag }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
A copy of these results will be sent to you via email.
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
46
ref-test/quiz/templates/quiz/start-quiz.html
Normal file
46
ref-test/quiz/templates/quiz/start-quiz.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{% extends "quiz/components/base.html" %}
|
||||||
|
{% import "bootstrap/wtf.html" as wtf %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-container">
|
||||||
|
<form name="form-quiz-start" class="form-quiz-start">
|
||||||
|
<h2 class="form-heading">Take the Exam</h2>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.first_name(class_="form-control", autofocus=true, placeholder="Enter First Name(s)") }}
|
||||||
|
{{ form.first_name.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.surname(class_="form-control", placeholder="Enter Surname") }}
|
||||||
|
{{ form.surname.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.email(class_="form-control", placeholder="Enter Email Address") }}
|
||||||
|
{{ form.email.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.club(class_="form-control", placeholder="Enter Affiliated Club") }}
|
||||||
|
{{ form.club.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.test_code(class_="form-control test-code-input", placeholder="Enter Exam Code") }}
|
||||||
|
{{ form.test_code.label }}
|
||||||
|
</div>
|
||||||
|
<div class="form-label-group">
|
||||||
|
{{ form.user_code(class_="form-control", placeholder="Enter User Code") }}
|
||||||
|
{{ form.user_code.label }}
|
||||||
|
</div>
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button class="btn btn-md btn-success btn-block" type="submit">
|
||||||
|
<i class="bi bi-pencil-fill button-icon"></i>
|
||||||
|
Get Ready
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
233
ref-test/quiz/views.py
Normal file
233
ref-test/quiz/views.py
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, jsonify, session, abort, flash
|
||||||
|
from flask.helpers import url_for
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
import os
|
||||||
|
from json import loads
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
from pymongo.collection import ReturnDocument
|
||||||
|
|
||||||
|
from common.security import encrypt
|
||||||
|
from common.data_tools import generate_questions, evaluate_answers
|
||||||
|
from common.security.database import decrypt_find_one
|
||||||
|
|
||||||
|
views = Blueprint(
|
||||||
|
'quiz_views',
|
||||||
|
__name__,
|
||||||
|
static_url_path='',
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static'
|
||||||
|
)
|
||||||
|
|
||||||
|
@views.route('/')
|
||||||
|
@views.route('/home/')
|
||||||
|
def home():
|
||||||
|
from main import db
|
||||||
|
_id = session.get('_id')
|
||||||
|
if _id and db.entries.find_one({'_id': _id}):
|
||||||
|
return redirect(url_for('quiz_views.start_quiz'))
|
||||||
|
return render_template('/quiz/index.html')
|
||||||
|
|
||||||
|
@views.route('/instructions/')
|
||||||
|
def instructions():
|
||||||
|
from main import db
|
||||||
|
_id = session.get('_id')
|
||||||
|
if _id and db.entries.find_one({'_id': _id}):
|
||||||
|
return redirect(url_for('quiz_views.start_quiz'))
|
||||||
|
return render_template('/quiz/instructions.html')
|
||||||
|
|
||||||
|
@views.route('/start/', methods = ['GET', 'POST'])
|
||||||
|
def start():
|
||||||
|
from main import db
|
||||||
|
from .forms import StartQuiz
|
||||||
|
form = StartQuiz()
|
||||||
|
if request.method == 'GET':
|
||||||
|
_id = session.get('_id')
|
||||||
|
if _id and db.entries.find_one({'_id': _id}):
|
||||||
|
return redirect(url_for('quiz_views.start_quiz'))
|
||||||
|
return render_template('/quiz/start-quiz.html', form=form)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
name = {
|
||||||
|
'first_name': request.form.get('first_name'),
|
||||||
|
'surname': request.form.get('surname')
|
||||||
|
}
|
||||||
|
email = request.form.get('email')
|
||||||
|
club = request.form.get('club')
|
||||||
|
test_code = request.form.get('test_code').replace('—', '').upper()
|
||||||
|
user_code = request.form.get('user_code')
|
||||||
|
user_code = None if user_code == '' else user_code.upper()
|
||||||
|
test = db.tests.find_one({'test_code': test_code})
|
||||||
|
if not test:
|
||||||
|
return jsonify({'error': 'The exam code you entered is invalid.'}), 400
|
||||||
|
if user_code and user_code not in test['time_adjustments']:
|
||||||
|
return jsonify({'error': f'The user code you entered is not valid.'}), 400
|
||||||
|
if test['expiry_date'] < datetime.utcnow():
|
||||||
|
return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y")} UTC.'}), 400
|
||||||
|
if test['start_date'] > datetime.utcnow():
|
||||||
|
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")} UTC.'}), 400
|
||||||
|
entry = {
|
||||||
|
'_id': uuid4().hex,
|
||||||
|
'name': encrypt(name),
|
||||||
|
'email': encrypt(email),
|
||||||
|
'club': encrypt(club),
|
||||||
|
'test_code': test_code,
|
||||||
|
'user_code': user_code
|
||||||
|
}
|
||||||
|
if db.entries.insert_one(entry):
|
||||||
|
session['_id'] = entry['_id']
|
||||||
|
return jsonify({
|
||||||
|
'success': 'Received and validated test and/or user code. Redirecting to test client.',
|
||||||
|
'_id': entry['_id']
|
||||||
|
}), 200
|
||||||
|
else:
|
||||||
|
errors = [*form.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@views.route('/api/questions/', methods=['POST'])
|
||||||
|
def fetch_questions():
|
||||||
|
from main import app, db
|
||||||
|
_id = request.get_json()['_id']
|
||||||
|
entry = db.entries.find_one({'_id': _id})
|
||||||
|
if not entry:
|
||||||
|
return jsonify({'error': 'The data that the client sent to the server is invalid. This is possibly because you have already submitted your exam and have tried to access the page again.'}), 400
|
||||||
|
test_code = entry['test_code']
|
||||||
|
user_code = entry['user_code']
|
||||||
|
test = db.tests.find_one({'test_code' : test_code})
|
||||||
|
time_limit = test['time_limit']
|
||||||
|
time_adjustment = 0
|
||||||
|
if time_limit:
|
||||||
|
_time_limit = int(time_limit)
|
||||||
|
if user_code:
|
||||||
|
time_adjustment = test['time_adjustments'][user_code]
|
||||||
|
_time_limit += time_adjustment
|
||||||
|
end_delta = timedelta(minutes=_time_limit)
|
||||||
|
end_time = datetime.utcnow() + end_delta
|
||||||
|
else:
|
||||||
|
end_time = None
|
||||||
|
update = {
|
||||||
|
'start_time': datetime.utcnow(),
|
||||||
|
'status': 'started',
|
||||||
|
'end_time': end_time
|
||||||
|
}
|
||||||
|
entry = db.entries.find_one_and_update({'_id': _id}, {'$set': update}, upsert=False, return_document=ReturnDocument.AFTER)
|
||||||
|
db.tests.find_one_and_update({'_id': test['_id']}, {'$push': {'entries': _id}})
|
||||||
|
dataset = test['dataset']
|
||||||
|
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
|
||||||
|
with open(dataset_path, 'r') as data_file:
|
||||||
|
data = loads(data_file.read())
|
||||||
|
questions = generate_questions(data)
|
||||||
|
return jsonify({
|
||||||
|
'time_limit': end_time,
|
||||||
|
'questions': questions,
|
||||||
|
'start_time': entry['start_time'],
|
||||||
|
'time_adjustment': time_adjustment
|
||||||
|
})
|
||||||
|
|
||||||
|
@views.route('/test/')
|
||||||
|
def start_quiz():
|
||||||
|
from main import db
|
||||||
|
_id = session.get('_id')
|
||||||
|
if not _id or not db.entries.find_one({'_id': _id}):
|
||||||
|
flash('Your log in was not recognised. Please sign in to the quiz again.', 'error')
|
||||||
|
return redirect(url_for('quiz_views.start'))
|
||||||
|
return render_template('quiz/client.html')
|
||||||
|
|
||||||
|
@views.route('/api/submit/', methods=['POST'])
|
||||||
|
def submit_quiz():
|
||||||
|
from main import app, db
|
||||||
|
_id = request.get_json()['_id']
|
||||||
|
answers = request.get_json()['answers']
|
||||||
|
entry = db.entries.find_one({'_id': _id})
|
||||||
|
if not entry:
|
||||||
|
return jsonify('Unrecognised ID', 'error'), 400
|
||||||
|
status = 'submitted'
|
||||||
|
if entry['end_time']:
|
||||||
|
if datetime.utcnow() > entry['end_time'] + timedelta(minutes=2):
|
||||||
|
status = 'late'
|
||||||
|
test_code = entry['test_code']
|
||||||
|
test = db.tests.find_one({'test_code' : test_code})
|
||||||
|
dataset = test['dataset']
|
||||||
|
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
|
||||||
|
with open(dataset_path, 'r') as _dataset:
|
||||||
|
data = loads(_dataset.read())
|
||||||
|
results = evaluate_answers(data, answers)
|
||||||
|
entry = db.entries.find_one_and_update({'_id': _id}, {'$set': {
|
||||||
|
'status': status,
|
||||||
|
'submission_time': datetime.utcnow(),
|
||||||
|
'results': results,
|
||||||
|
'answers': answers
|
||||||
|
}})
|
||||||
|
return jsonify({
|
||||||
|
'success': 'Your submission has been processed. Redirecting you to receive your results.',
|
||||||
|
'_id': _id
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@views.route('/result/')
|
||||||
|
def result():
|
||||||
|
from main import db, mail
|
||||||
|
_id = session.get('_id')
|
||||||
|
entry = decrypt_find_one(db.entries, {'_id': _id})
|
||||||
|
if not entry:
|
||||||
|
return abort(404)
|
||||||
|
score = round(100*entry['results']['score']/entry['results']['max'])
|
||||||
|
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in entry['results']['tags'].items() }
|
||||||
|
sorted_low_tags = sorted(tags_low.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
tag_output = [ tag[0] for tag in sorted_low_tags[0:3] if tag[1] > 3]
|
||||||
|
if entry['results']['grade'] == 'pass':
|
||||||
|
flavour_text_plain = """Well done on successfully completing the refereeing theory exam. We really appreciate members of our community taking the time to get qualified to referee.
|
||||||
|
"""
|
||||||
|
elif entry['results']['grade'] == 'merit':
|
||||||
|
flavour_text_plain = """Congratulations on achieving a merit in the refereeing exam. We are delighted that members of our community work so hard with refereeing.
|
||||||
|
"""
|
||||||
|
elif entry['results']['grade'] == 'fail':
|
||||||
|
flavour_text_plain = """Unfortunately, you were not successful in passing the theory exam in this attempt. We hope that this does not dissuade you, and that you try again in the future.
|
||||||
|
"""
|
||||||
|
if not entry['status'] == 'late':
|
||||||
|
email = Message(
|
||||||
|
subject="SKA Refereeing Theory Exam Results",
|
||||||
|
recipients=[entry['email']],
|
||||||
|
body=f"""SKA Refereeing Theory Exam\n\n
|
||||||
|
Candidate Results\n\n
|
||||||
|
Dear {entry['name']['first_name']},\n\n
|
||||||
|
This email is to confirm that you have took the SKA Refereeing Theory Exam. Your test has been evaluated and your results have been generated.\n\n
|
||||||
|
{entry['name']['surname']}, {entry['name']['first_name']}\n\n
|
||||||
|
Email Address: {entry['email']}\n
|
||||||
|
{f"Club: {entry['club']}" if entry['club'] else ''}\n
|
||||||
|
Date of Test: {entry['submission_time'].strftime('%d %b %Y')}\n
|
||||||
|
Score: {score}%\n
|
||||||
|
Grade: {entry['results']['grade']}\n\n
|
||||||
|
{flavour_text_plain}\n\n
|
||||||
|
Based on your answers, we would also suggest you brush up on the following topics as you continue refereeing:\n\n
|
||||||
|
{','.join(tag_output)}\n\n
|
||||||
|
Thank you for taking the time to get qualified as a referee.\n\n
|
||||||
|
Best wishes,\n
|
||||||
|
SKA Refereeing
|
||||||
|
""",
|
||||||
|
html=f"""<h1>SKA Refereeing Theory Exam</h1>
|
||||||
|
<h2>Candidate Results</h2>
|
||||||
|
<p>Dear {entry['name']['first_name']},</p>
|
||||||
|
<p>This email is to confirm that you have took the SKA Refereeing Theory Exam. Your test has been evaluated and your results have been generated.</p>
|
||||||
|
<h3>{entry['name']['surname']}, {entry['name']['first_name']}</h3>
|
||||||
|
<p><strong>Email Address</strong>: {entry['email']}</p>
|
||||||
|
{f"<p><strong>Club</strong>: {entry['club']}</p>" if entry['club'] else ''}
|
||||||
|
<p><strong>Date of Test</strong>: {entry['submission_time'].strftime('%d %b %Y')}</p>
|
||||||
|
<h1>{score}%</h1>
|
||||||
|
<h2>{entry['results']['grade']}</h2>
|
||||||
|
<p>{flavour_text_plain}</p>
|
||||||
|
<p>Based on your answers, we would also suggest you revise the following topics as you continue refereeing:</p>
|
||||||
|
<ul>
|
||||||
|
<li>{'</li><li>'.join(tag_output)}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Thank you for taking the time to get qualified as a referee.</p>
|
||||||
|
<p>Best wishes,<br />
|
||||||
|
SKA Refereeing</p>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
mail.send(email)
|
||||||
|
return render_template('/quiz/result.html', entry=entry, score=score, tag_output=tag_output)
|
||||||
|
|
||||||
|
@views.route('/privacy/')
|
||||||
|
def privacy():
|
||||||
|
return render_template('/quiz/privacy.html')
|
23
ref-test/requirements.txt
Normal file
23
ref-test/requirements.txt
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
blinker==1.4
|
||||||
|
cffi==1.15.0
|
||||||
|
click==8.0.3
|
||||||
|
cryptography==36.0.0
|
||||||
|
dnspython==2.1.0
|
||||||
|
dominate==2.6.0
|
||||||
|
email-validator==1.1.3
|
||||||
|
Flask==2.0.2
|
||||||
|
Flask-Bootstrap==3.3.7.1
|
||||||
|
Flask-Mail==0.9.1
|
||||||
|
Flask-WTF==1.0.0
|
||||||
|
gunicorn==20.1.0
|
||||||
|
idna==3.3
|
||||||
|
itsdangerous==2.0.1
|
||||||
|
Jinja2==3.0.3
|
||||||
|
MarkupSafe==2.0.1
|
||||||
|
pip-autoremove==0.10.0
|
||||||
|
pycparser==2.21
|
||||||
|
pymongo==4.0
|
||||||
|
python-dotenv==0.19.2
|
||||||
|
visitor==0.1.3
|
||||||
|
Werkzeug==2.0.2
|
||||||
|
WTForms==3.0.0
|
4
ref-test/wsgi.py
Normal file
4
ref-test/wsgi.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from main import app
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run()
|
2
src/html/robots.txt
Normal file
2
src/html/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
Loading…
Reference in New Issue
Block a user