Compare commits
No commits in common. "43895bead0d1b0c449f9e9051af665d7748e396a" and "f170ff5e52b5cda69c0938eee821a906deefabe6" have entirely different histories.
43895bead0
...
f170ff5e52
20
.env.example
@ -1,20 +0,0 @@
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
|
||||
## Flask Configuration
|
||||
SECRET_KEY= # Long, secure, secret string.
|
||||
DATA=./data/
|
||||
|
||||
## Flask Mail Configuration
|
||||
MAIL_SERVER=postfix # Must match name of the Docker service
|
||||
MAIL_PORT=25
|
||||
MAIL_USE_TLS=False
|
||||
MAIL_USE_SSL=False
|
||||
MAIL_USERNAME= # Username@domain, must match config values below
|
||||
MAIL_PASSWORD= # Must match config value below
|
||||
MAIL_DEFAULT_SENDER= # NoReply@domain or some such.
|
||||
MAIL_MAX_EMAILS=25
|
||||
MAIL_ASCII_ATTACHMENTS=True
|
||||
|
||||
# Postfix
|
||||
maildomain= # Domain must match the section of username above
|
||||
smtp_user= # username:password. Must match config values above.
|
149
README.md
@ -14,154 +14,27 @@ The clien is designed to work on a server.
|
||||
|
||||
### Pre-Requisites
|
||||
|
||||
- A Debian- or Ubuntu-based server, preferably the latest distribution.
|
||||
- Docker (specifically, Docker Engine)
|
||||
- Docker Compose
|
||||
- Git
|
||||
Server
|
||||
Docker
|
||||
Docker-Compose
|
||||
Git
|
||||
|
||||
### Installation
|
||||
|
||||
#### Install all the pre-requisites
|
||||
|
||||
The first step is to ensure all the prerequisites are available on the server.
|
||||
|
||||
To set up the server, consult some of the comprehensive guides on various hosting platforms like Linode or DigitalOcean.
|
||||
Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04).
|
||||
|
||||
To install Docker and Docker Compose, consult the respective documentation:
|
||||
- [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/)
|
||||
- Docker Compose should be installed as part of that.
|
||||
|
||||
```
|
||||
At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions.
|
||||
```
|
||||
|
||||
Check if Git is installed on your server using the `git --version` command.
|
||||
If it isn't installed, install it.
|
||||
This should normally come pre-packaged with your OS distribution.
|
||||
But if it doesn't, look up how to for whatever OS you use.
|
||||
If you are using Ubuntu or Debian, it should be as easy as using the command:
|
||||
|
||||
```$ sudo apt-get install git -y```
|
||||
|
||||
#### Preliminary Set-Up: Clone repos and Configure Values
|
||||
|
||||
Open a terminal and navigate to the folder where you want to install this app.
|
||||
I would suggest using a subfolder within your Home folder:
|
||||
#### Set Up Web Server
|
||||
|
||||
```$ cd ~ && mkdir ska-referee-test && cd ska-referee-test```
|
||||
#### Incorporate SSL
|
||||
|
||||
That way, you will ensure you can read and write all the necessary files during installation.
|
||||
Once in the destination folder, clone all the relevant files you will need for the installation:
|
||||
#### Set Up Auto-Renew
|
||||
|
||||
```$ git clone https://git.vsnt.uk/viveksantayana/ska-referee-test.git .```
|
||||
### Alterations
|
||||
|
||||
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
|
||||
## Use
|
||||
|
||||
#### Populate Environment Variables
|
||||
## Compatibility
|
||||
|
||||
Configuration values for the app are stored in the environment variables file.
|
||||
To set it up, make a copy of the example file and populate it with appropriate values.
|
||||
|
||||
```$ cp .env.example .env```
|
||||
|
||||
Make sure to use complex, secure strings for passwords.
|
||||
Also make sure that the various entries for usernames and passwords match.
|
||||
|
||||
#### Input Specific Values for Your Installation
|
||||
|
||||
There are some values in the following four files you will need to configure to reflect the domain you are installing this app.
|
||||
|
||||
```
|
||||
# .env
|
||||
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
```
|
||||
|
||||
```
|
||||
# install-script.sh
|
||||
|
||||
domains=(example.org www.example.org)
|
||||
email="" # Adding a valid address is strongly recommended
|
||||
```
|
||||
|
||||
Substitute the domain name `domain_name` in the two file paths in the following file:
|
||||
|
||||
```
|
||||
# nginx/ssl.conf
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
||||
...
|
||||
```
|
||||
|
||||
And **six** locations in the following file, two for the regular version of the domain and two for the www version:
|
||||
|
||||
```
|
||||
# nginx/conf.d/ref-test-app.conf
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 80 default_server;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 443 ssl http2 default_server;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri; ...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
...
|
||||
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
#### Installing SSL Certificates
|
||||
|
||||
The app will use SSL certificates to operate through a secure, `https` connection.
|
||||
This will be set up automatically.
|
||||
However, there is a specific chicken-and-egg problem as the web server, Nginx, won't run without certificates, Certbot, the certificate generator, won't run without the web server.
|
||||
So to solve this, there is an automation script we can run that will set up a dummy certificate and then issue the appropriate certificates for us.
|
||||
|
||||
```
|
||||
$ chmod +x install-script.sh
|
||||
$ sudo ./install-script.sh
|
||||
```
|
||||
|
||||
This will take a long time to run the first time because it will try and generate a fairly sizeable cypher.
|
||||
|
||||
When we later run the server, Certbot will check for renewals of the SSL certificates every 12 hours, and Nginx will reload the configurations every 6 hours, to make sure everything runs smoothly and stays live.
|
||||
|
||||
#### Run the Stack
|
||||
|
||||
Everything should be good to run on autopilot at this point.
|
||||
Navigate to the root folder of the app, the folder where you have `install-script.sh` and `docker-compose.yml`.
|
||||
Run the following command:
|
||||
|
||||
```sudo docker compose up -d```
|
||||
|
||||
And you should have the stack running.
|
||||
You can register in the app and begin using it.
|
||||
### iOS Limitations
|
||||
|
||||
### Fonts
|
||||
|
||||
The app uses [OpenDyslexic](https://opendyslexic.org/), which is available on-line.
|
||||
It also has the option of rendering in other system fonts, but this can vary depending on your operating system.
|
||||
Because these are proprietary fonts, they cannot be made available on-line in the same way as open source ones should your system not have them.
|
||||
Some fonts may not display correctly as a result.
|
||||
|
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
|
@ -1,16 +1,15 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
container_name: reftest_server
|
||||
image: nginx:alpine
|
||||
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/app/admin/static:/usr/share/nginx/html/admin/static
|
||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
|
||||
- ./ref-test/app/root:/usr/share/nginx/html/root
|
||||
- ./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
|
||||
@ -18,11 +17,10 @@ services:
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- app
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
- ref_test_app
|
||||
|
||||
app:
|
||||
container_name: reftest_app
|
||||
ref_test_app:
|
||||
container_name: ref_test_app
|
||||
image: reftest
|
||||
build: ./ref-test
|
||||
env_file:
|
||||
@ -30,16 +28,32 @@ services:
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- ./.security:/ref-test/.security
|
||||
- ./ref-test/data:/ref-test/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
depends_on:
|
||||
- postfix
|
||||
- ref_test_db
|
||||
- ref_test_postfix
|
||||
|
||||
postfix:
|
||||
container_name: reftest_postfix
|
||||
ref_test_db:
|
||||
container_name: ref_test_db
|
||||
image: mongo:5.0.4-focal
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./database/data:/data/db
|
||||
- ./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:
|
||||
@ -49,13 +63,15 @@ services:
|
||||
networks:
|
||||
- backend
|
||||
|
||||
certbot:
|
||||
container_name: reftest_certbot
|
||||
image: certbot/certbot
|
||||
ref_test_certbot:
|
||||
container_name: ref_test_certbot
|
||||
image: certbot/certbot:v1.21.0
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt
|
||||
- ./src/html:/var/www/html
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
depends_on:
|
||||
- ref_test_server
|
||||
# command: certonly --webroot --webroot-path=/var/www/html --email (email) --agree-tos --no-eff-email -d (domain)
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
|
@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
|
||||
|
||||
if ! [ -x "$(command -v docker compose)" ]; then
|
||||
echo 'Error: docker compose is not installed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
domains=(example.org www.example.org)
|
||||
rsa_key_size=4096
|
||||
data_path="./certbot"
|
||||
email="" # Adding a valid address is strongly recommended
|
||||
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
|
||||
|
||||
if [ -d "$data_path" ]; then
|
||||
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
|
||||
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -e "$data_path/ssl-dhparams.pem" ]; then
|
||||
echo "### Generating ssl-dhparams.pem ..."
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "### Creating dummy certificate for $domains ..."
|
||||
path="/etc/letsencrypt/live/$domains"
|
||||
mkdir -p "$data_path/live/$domains"
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
|
||||
-keyout '$path/privkey.pem' \
|
||||
-out '$path/fullchain.pem' \
|
||||
-subj '/CN=localhost'" certbot
|
||||
echo
|
||||
|
||||
if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
|
||||
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
|
||||
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
|
||||
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "### Starting nginx ..."
|
||||
docker compose up --force-recreate -d nginx
|
||||
echo
|
||||
|
||||
echo "### Deleting dummy certificate for $domains ..."
|
||||
docker compose run --rm --entrypoint "\
|
||||
rm -Rf /etc/letsencrypt/live/$domains && \
|
||||
rm -Rf /etc/letsencrypt/archive/$domains && \
|
||||
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
|
||||
echo
|
||||
|
||||
echo "### Requesting Let's Encrypt certificate for $domains ..."
|
||||
#Join $domains to -d args
|
||||
domain_args=""
|
||||
for domain in "${domains[@]}"; do
|
||||
domain_args="$domain_args -d $domain"
|
||||
done
|
||||
|
||||
# Select appropriate email arg
|
||||
case "$email" in
|
||||
"") email_arg="--register-unsafely-without-email" ;;
|
||||
*) email_arg="--email $email" ;;
|
||||
esac
|
||||
|
||||
# Enable staging mode if needed
|
||||
if [ $staging != "0" ]; then staging_arg="--staging"; fi
|
||||
|
||||
docker compose run --rm --entrypoint "\
|
||||
certbot certonly --webroot -w /var/www/html \
|
||||
$staging_arg \
|
||||
$email_arg \
|
||||
$domain_args \
|
||||
--rsa-key-size $rsa_key_size \
|
||||
--agree-tos \
|
||||
--force-renewal" certbot
|
||||
echo
|
||||
|
||||
echo "### Reloading nginx ..."
|
||||
docker compose exec nginx nginx -s reload
|
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;
|
||||
}
|
||||
|
||||
}
|
@ -1,25 +1,25 @@
|
||||
upstream reftest {
|
||||
server app:5000;
|
||||
server ref_test_app:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# Redirect to ssl
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
# SSL configuration
|
||||
#SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
include /etc/nginx/certbot-challenge.conf;
|
||||
|
||||
location ^~ /quiz/static/ {
|
||||
location ^~ /static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/quiz/static/;
|
||||
}
|
||||
@ -30,28 +30,7 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
include /etc/nginx/conf.d/proxy_headers.conf;
|
||||
include /etc/nginx/conf.d/common-location.conf;
|
||||
proxy_pass http://reftest;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
# SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
include /etc/nginx/certbot-challenge.conf;
|
||||
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
@ -1,13 +1,2 @@
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
||||
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_trusted_certificate /etc/letsencrypt/lets-encrypt-x3-cross-signed.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
ssl_session_cache shared:SSL:40m;
|
||||
ssl_session_timeout 4h;
|
||||
ssl_session_tickets on;
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem; # managed by Certbot
|
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
|
@ -1,64 +1,62 @@
|
||||
from ..tools.forms import value
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileAllowed, FileField, FileRequired
|
||||
from wtforms import BooleanField, IntegerField, PasswordField, SelectField, StringField
|
||||
from wtforms.fields import DateTimeLocalField
|
||||
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
|
||||
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 datetime import date, datetime, timedelta
|
||||
from .validators import value
|
||||
|
||||
class Login(FlaskForm):
|
||||
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 Register(FlaskForm):
|
||||
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 ResetPassword(FlaskForm):
|
||||
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 UpdatePassword(FlaskForm):
|
||||
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 CreateUser(FlaskForm):
|
||||
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.')])
|
||||
notify = BooleanField('Notify accout creation by email', render_kw={'checked': True})
|
||||
|
||||
class DeleteUser(FlaskForm):
|
||||
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 UpdateUser(FlaskForm):
|
||||
confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
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 UpdateAccount(FlaskForm):
|
||||
confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
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 = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = datetime.now() )
|
||||
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = date.today() + timedelta(days=1) )
|
||||
start_date = 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 UploadData(FlaskForm):
|
||||
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)])
|
||||
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
|
||||
|
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
@ -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
|
207
ref-test/admin/models/users.py
Normal file
@ -0,0 +1,207 @@
|
||||
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):
|
||||
from main import app
|
||||
resp.set_cookie(
|
||||
key = '_id',
|
||||
value = self._id,
|
||||
max_age = timedelta(days=14) if self.remember else None,
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=14) if self.remember else None,
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
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 = f'.{app.config["SERVER_NAME"]}',
|
||||
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')))
|
||||
from main import app
|
||||
resp.set_cookie(
|
||||
key = '_id',
|
||||
value = '',
|
||||
max_age = timedelta(days=-1),
|
||||
path = '/',
|
||||
expires= datetime.utcnow() + timedelta(days=-1),
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
secure = True
|
||||
)
|
||||
resp.set_cookie (
|
||||
key = 'cookie_consent',
|
||||
value = 'True',
|
||||
max_age = None,
|
||||
path = '/',
|
||||
expires = None,
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
secure = True
|
||||
)
|
||||
resp.set_cookie (
|
||||
key = 'remember',
|
||||
value = 'True',
|
||||
max_age = timedelta(days=-1),
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=-1),
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
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
@ -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')
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
@ -70,14 +70,14 @@ $('form[name=form-upload-questions]').submit(function(event) {
|
||||
// Edit and Delete Test Button Handlers
|
||||
$('.test-action').click(function(event) {
|
||||
|
||||
let id = $(this).data('id');
|
||||
let _id = $(this).data('_id');
|
||||
let action = $(this).data('action');
|
||||
|
||||
if (action == 'delete' || action == 'start' || action == 'end') {
|
||||
if (action == 'delete') {
|
||||
$.ajax({
|
||||
url: `/admin/tests/edit/`,
|
||||
url: `/admin/tests/delete/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'id': id, 'action': action}), // TODO Change how CRUD operations work
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = '/admin/tests/';
|
||||
@ -87,7 +87,21 @@ $('.test-action').click(function(event) {
|
||||
},
|
||||
});
|
||||
} else if (action == 'edit') {
|
||||
window.location.href = `/admin/test/${id}/`
|
||||
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();
|
||||
@ -152,7 +166,7 @@ $('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'POST',
|
||||
type: 'GET',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
@ -171,13 +185,13 @@ $('#dismiss-cookie-alert').click(function(event){
|
||||
// Script for Result Actions
|
||||
$('.result-action-buttons').click(function(event){
|
||||
|
||||
var id = $(this).data('id');
|
||||
var _id = $(this).data('_id');
|
||||
|
||||
if ($(this).data('result-action') == 'generate') {
|
||||
$.ajax({
|
||||
url: '/admin/certificate/',
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'id': id}),
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
contentType: 'application/json',
|
||||
dataType: 'html',
|
||||
success: function(response) {
|
||||
@ -193,7 +207,7 @@ $('.result-action-buttons').click(function(event){
|
||||
$.ajax({
|
||||
url: window.location.href,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'id': id, 'action': action}),
|
||||
data: JSON.stringify({'_id': _id, 'action': action}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
if (action == 'delete') {
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% 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._home') }}">
|
||||
<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() }}
|
||||
@ -32,7 +32,7 @@
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<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>
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._home') }}">
|
||||
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form">Log In</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('admin._reset') }}" class="signin-forgot-password">Reset Password</a>
|
||||
<a href="{{ url_for('admin_auth.reset') }}" class="signin-forgot-password">Reset Password</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -3,14 +3,14 @@
|
||||
{% block navbar %}
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<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._login') }}">
|
||||
<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() }}
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% 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._login') }}">
|
||||
<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() }}
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for(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() }}
|
@ -15,30 +15,30 @@
|
||||
<h5 class="mb-1">Candidate</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ entry.get_surname()}}, {{ entry.get_first_name() }}
|
||||
{{ 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.get_email() }}
|
||||
{{ entry.email }}
|
||||
</li>
|
||||
{% if entry.club %}
|
||||
{% 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.get_club() }}
|
||||
{{ 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>
|
||||
{{ entry.test.get_code() }}
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
</li>
|
||||
{% if entry.user_code %}
|
||||
{% 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>
|
||||
@ -59,19 +59,19 @@
|
||||
<span class="badge bg-danger">Late</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{{ 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.result.score }}%
|
||||
{{ entry.results.score }}%
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<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.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
|
||||
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="site-footer mt-5">
|
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>
|
@ -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>
|
@ -25,22 +25,22 @@
|
||||
{% for test in current_tests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||
<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.end_date.strftime('%d %b %Y') }}
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" class="btn btn-primary">View Exams</a>
|
||||
<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._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -69,20 +69,20 @@
|
||||
{% for result in recent_results %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
|
||||
<a href="{{ url_for('admin_views.view_entry', _id=result._id) }}">{{ result.name.surname }}, {{ result.name.first_name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ result.end_time.strftime('%d %b %Y %H:%M') }}
|
||||
{{ result.submission_time.strftime('%d %b %Y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
||||
{{ result.percent }}% ({{ result.results.grade }})
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin._view_entries') }}" class="btn btn-primary">View Results</a>
|
||||
<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.
|
||||
@ -114,22 +114,22 @@
|
||||
{% for test in upcoming_tests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||
<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.end_date.strftime('%d %b %Y') }}
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
|
||||
<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._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
@ -13,30 +13,30 @@
|
||||
<h5 class="mb-1">Candidate</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||
{{ 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.get_email() }}
|
||||
{{ entry.email }}
|
||||
</li>
|
||||
{% if entry.club %}
|
||||
{% 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.get_club() }}
|
||||
{{ 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>
|
||||
{{ entry.test.get_code() }}
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
</li>
|
||||
{% if entry.user_code %}
|
||||
{% 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>
|
||||
@ -44,7 +44,7 @@
|
||||
{{ entry.user_code }}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if entry.start_time %}
|
||||
{% if 'start_time' in entry %}
|
||||
<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>
|
||||
@ -59,28 +59,28 @@
|
||||
<span class="badge bg-danger">Late</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{% if 'submission_time' in entry %}
|
||||
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{% else %}
|
||||
Incomplete
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if entry.result %}
|
||||
{% 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.result.score }}%
|
||||
{{ entry.results.score }}%
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<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.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
|
||||
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if entry.result %}
|
||||
{% if 'results' in entry %}
|
||||
<div class="accordion" id="results-breakdown">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="by-category">
|
||||
@ -105,7 +105,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag, scores in entry.result.tags.items() %}
|
||||
{% for tag, scores in entry.results.tags.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ tag }}
|
||||
@ -149,8 +149,8 @@
|
||||
{{ question }}
|
||||
</td>
|
||||
<td>
|
||||
{{ answers[question|int][answer|int] }}
|
||||
{% if not correct[question] == answer|int %}
|
||||
{{ answer }}
|
||||
{% if not correct[question] == answer %}
|
||||
<span class="badge badge-pill bg-danger badge-danger">Incorrect</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
@ -164,19 +164,19 @@
|
||||
{% 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 }}">
|
||||
<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 }}">
|
||||
<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 }}">
|
||||
<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>
|
@ -37,41 +37,41 @@
|
||||
{% for entry in entries %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||
{{ entry.name.surname }}, {{ entry.name.first_name }}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.club %}
|
||||
{{ entry.get_club() }}
|
||||
{% if 'club' in entry %}
|
||||
{{ entry.club }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ entry.test.get_code() }}
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.status %}
|
||||
{% if 'status' in entry %}
|
||||
{{ entry.status }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%d %b %Y') }}
|
||||
{% if 'submission_time' in entry %}
|
||||
{{ entry.submission_time.strftime('%d %b %Y') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.result %}
|
||||
{{ entry.result.score }}%
|
||||
{% if 'results' in entry %}
|
||||
{{ entry.results.score }}%
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.result %}
|
||||
{{ entry.result.grade }}
|
||||
{% if 'results' in entry %}
|
||||
{{ entry.results.grade }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="{{ url_for('admin._view_entry', id = entry.id ) }}"
|
||||
href="{{ url_for('admin_views.view_entry', _id = entry._id ) }}"
|
||||
class="btn btn-primary entry-details"
|
||||
data-id="{{entry.id}}"
|
||||
data-_id="{{entry._id}}"
|
||||
title="View Details"
|
||||
>
|
||||
<i class="bi bi-file-medical-fill button-icon"></i>
|
@ -2,11 +2,11 @@
|
||||
|
||||
{% 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._users') }}">
|
||||
<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.get_username() }}’?</h2>
|
||||
<h2 class="form-heading">Delete User ‘{{ user.username }}’?</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<p>This action cannot be undone. Deleting an account will mean {{ user.get_username() }} will no longer be able to log in to the admin console.</p>
|
||||
<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) }}
|
||||
@ -20,7 +20,7 @@
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin._users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
|
||||
<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>
|
@ -28,22 +28,22 @@
|
||||
<tr>
|
||||
<td>
|
||||
<a href="
|
||||
{% if user == current_user %}
|
||||
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||
{% if user._id == get_id_from_cookie() %}
|
||||
{{ url_for('admin_auth.account') }}
|
||||
{% else %}
|
||||
{{ url_for('admin._update_user', id=user.id) }}
|
||||
{{ url_for('admin_views.update_user', _id=user._id) }}
|
||||
{% endif%}
|
||||
">{{ user.get_username() }}</a>
|
||||
">{{ user.username }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="mailto:{{ user.get_email() }}">{{ user.get_email() }}</a>
|
||||
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-primary">Manage Users</a>
|
||||
<a href="{{ url_for('admin_views.users') }}" class="btn btn-primary">Manage Users</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -57,7 +57,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Uploaded
|
||||
File Name
|
||||
</th>
|
||||
<th>
|
||||
Exams
|
||||
@ -68,22 +68,22 @@
|
||||
{% for dataset in datasets %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
|
||||
{{ dataset.filename }}
|
||||
</td>
|
||||
<td>
|
||||
{{ dataset.tests|length }}
|
||||
{{ dataset.use }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Manage Datasets</a>
|
||||
<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._questions') }}" class="btn btn-primary">Upload Dataset</a>
|
||||
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Upload Dataset</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
@ -9,6 +9,9 @@
|
||||
<tr>
|
||||
<th>
|
||||
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
File Name
|
||||
</th>
|
||||
<th data-priority="2">
|
||||
Uploaded
|
||||
@ -28,7 +31,7 @@
|
||||
{% for element in data %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{% if element.default %}
|
||||
{% 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"/>
|
||||
@ -37,13 +40,16 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.date.strftime('%d %b %Y %H:%M') }}
|
||||
{{ element.filename }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.creator.get_username() }}
|
||||
{{ element.timestamp.strftime('%d %b %Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.tests|length }}
|
||||
{{ element.author }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.use }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
@ -106,10 +112,10 @@
|
||||
$(document).ready(function() {
|
||||
$('#question-datasets-table').DataTable({
|
||||
'columnDefs': [
|
||||
{'sortable': false, 'targets': [0,4]},
|
||||
{'searchable': false, 'targets': [0,3,4]}
|
||||
{'sortable': false, 'targets': [0,5]},
|
||||
{'searchable': false, 'targets': [0,4,5]}
|
||||
],
|
||||
'order': [[1, 'desc'], [2, 'asc']],
|
||||
'order': [[2, 'desc'], [3, 'asc']],
|
||||
'responsive': 'true',
|
||||
'fixedHeader': 'true',
|
||||
});
|
@ -2,12 +2,12 @@
|
||||
|
||||
{% 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._users') }}">
|
||||
<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.get_username() }}’</h2>
|
||||
<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.get_email()) }}
|
||||
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}
|
||||
{{ form.email.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
@ -23,17 +23,17 @@
|
||||
{{ form.notify.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
Please confirm <strong>your current password</strong> before committing any changes to a user account.
|
||||
Please confirm <strong>your password</strong> before committing any changes to a user account.
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.confirm_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
||||
{{ form.confirm_password.label }}
|
||||
{{ 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._users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<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>
|
@ -23,7 +23,7 @@
|
||||
{% for user in users %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{% if user == current_user %}
|
||||
{% 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"/>
|
||||
@ -32,18 +32,18 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ user.get_username() }}
|
||||
{{ user.username }}
|
||||
</td>
|
||||
<td>
|
||||
{{ user.get_email() }}
|
||||
{{ user.email }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="
|
||||
{% if not user == current_user %}
|
||||
{{ url_for('admin._update_user', id = user.id ) }}
|
||||
{% if not user._id == get_id_from_cookie() %}
|
||||
{{ url_for('admin_views.update_user', _id = user._id ) }}
|
||||
{% else %}
|
||||
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||
{{ url_for('admin_auth.account') }}
|
||||
{% endif %}
|
||||
"
|
||||
class="btn btn-primary"
|
||||
@ -53,15 +53,15 @@
|
||||
</a>
|
||||
<a
|
||||
href="
|
||||
{% if not user == current_user %}
|
||||
{{ url_for('admin._delete_user', id = user.id ) }}
|
||||
{% 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 == current_user %} disabled {% endif %}"
|
||||
class="btn btn-danger {% if user._id == get_id_from_cookie() %} disabled {% endif %}"
|
||||
title="Delete User"
|
||||
{% if user == current_user %} onclick="return false" {% endif %}
|
||||
{% if user._id == get_id_from_cookie() %} onclick="return false" {% endif %}
|
||||
>
|
||||
<i class="bi bi-person-x-fill button-icon"></i>
|
||||
</button>
|
@ -12,33 +12,38 @@
|
||||
<h5 class="mb-1">Exam Code</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ test.get_code() }}
|
||||
{{ '—'.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.date.strftime('%Y%m%d%H%M%S') }}
|
||||
{{ 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.get_username() }}
|
||||
{{ 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 %H:%M') }}
|
||||
{{ 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.end_date.strftime('%d %b %Y %H:%M') }}
|
||||
{{ 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">
|
||||
@ -57,7 +62,7 @@
|
||||
{% endif %}
|
||||
</li>
|
||||
<div class="accordion" id="test-info-detail">
|
||||
{% if test.entries|length > 0 %}
|
||||
{% 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">
|
||||
@ -71,7 +76,7 @@
|
||||
{% for entry in test.entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin._view_entry', id=entry) }}" >Entry {{ loop.index }}</a>
|
||||
<a href="{{ url_for('admin_views.view_entry', _id=entry) }}" >Entry {{ loop.index }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -81,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if test.adjustments %}
|
||||
{% 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">
|
||||
@ -105,10 +110,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, value in test.adjustments.items() %}
|
||||
{% for key, value in test.time_adjustments.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ key.upper() }}
|
||||
{{ key }}
|
||||
</td>
|
||||
<td>
|
||||
{{ value }}
|
||||
@ -138,7 +143,7 @@
|
||||
<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 Time") }}
|
||||
{{ form.time(class_="form-control", placeholder="Enter Username") }}
|
||||
{{ form.time.label }}
|
||||
</div>
|
||||
<div class="container form-submission-button">
|
||||
@ -163,18 +168,11 @@
|
||||
</div>
|
||||
<div class="container justify-content-center">
|
||||
<div class="row">
|
||||
{% if test.start_date <= now %}
|
||||
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
|
||||
<i class="bi bi-hourglass-bottom button-icon"></i>
|
||||
Close Exam
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-success test-action {% if test.start_date < now %}disabled{% endif %}" data-action="start" data-id="{{ test.id }}">
|
||||
<i class="bi bi-hourglass-top button-icon"></i>
|
||||
Start Exam
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
|
||||
<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>
|
@ -33,13 +33,13 @@
|
||||
{% for test in tests %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ test.start_date.strftime('%d %b %y %H:%M') }}
|
||||
{{ test.start_date.strftime('%d %b %Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ test.get_code() }}
|
||||
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
|
||||
</td>
|
||||
<td>
|
||||
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{% if test.time_limit == None -%}
|
||||
@ -61,7 +61,7 @@
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-primary test-action"
|
||||
data-id="{{test.id}}"
|
||||
data-_id="{{test._id}}"
|
||||
title="Edit Exam"
|
||||
data-action="edit"
|
||||
>
|
||||
@ -70,7 +70,7 @@
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-danger test-action"
|
||||
data-id="{{test.id}}"
|
||||
data-_id="{{test._id}}"
|
||||
title="Delete Exam"
|
||||
data-action="delete"
|
||||
>
|
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.data_file.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)
|
@ -1,111 +0,0 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-results">
|
||||
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-tests">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-tests"
|
||||
role="button"
|
||||
href="{{ url_for('admin._tests') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Exams
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-settings">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
@ -1,23 +0,0 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,392 +0,0 @@
|
||||
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
|
||||
from ..models import Dataset, Entry, Test, User
|
||||
from ..tools.auth import disable_if_logged_in, require_account_creation
|
||||
from ..tools.forms import get_dataset_choices, get_time_options
|
||||
from ..tools.data import check_is_json, validate_json
|
||||
from ..tools.test import answer_options, get_correct_answers
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, redirect, request, session
|
||||
from flask.helpers import flash, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from datetime import date, datetime
|
||||
from json import loads
|
||||
import secrets
|
||||
|
||||
admin = Blueprint(
|
||||
name='admin',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@admin.route('/')
|
||||
@admin.route('/home/')
|
||||
@admin.route('/dashboard/')
|
||||
@login_required
|
||||
def _home():
|
||||
tests = Test.query.all()
|
||||
results = Entry.query.all()
|
||||
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
|
||||
current_tests.sort(key= lambda x: x.end_date, reverse=True)
|
||||
upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()]
|
||||
upcoming_tests.sort(key= lambda x: x.start_date)
|
||||
recent_results = [result for result in results if not result.status == 'started' ]
|
||||
recent_results.sort(key= lambda x: x.end_time, reverse=True)
|
||||
return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results)
|
||||
|
||||
@admin.route('/settings/')
|
||||
@login_required
|
||||
def _settings():
|
||||
users = User.query.all()
|
||||
datasets = Dataset.query.all()
|
||||
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
|
||||
|
||||
@admin.route('/login/', methods=['GET','POST'])
|
||||
@disable_if_logged_in
|
||||
@require_account_creation
|
||||
def _login():
|
||||
form = Login()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
users = User.query.all()
|
||||
user = None
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username').lower():
|
||||
user = _user
|
||||
break
|
||||
if user:
|
||||
if user.verify_password(request.form.get('password')):
|
||||
user.login(remember=request.form.get('remember'))
|
||||
return jsonify({'success': f'Successfully logged in.'}), 200
|
||||
return jsonify({'error': f'The password you entered is incorrect.'}), 401
|
||||
return jsonify({'error': f'The username you entered does not exist.'}), 401
|
||||
errors = [*form.username.errors, *form.password.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
if 'remembered_username' in session: form.username.data = session.pop('remembered_username')
|
||||
next = request.args.get('next')
|
||||
return render_template('/admin/auth/login.html', form=form, next=next)
|
||||
|
||||
@admin.route('/logout/')
|
||||
@login_required
|
||||
def _logout():
|
||||
current_user.logout()
|
||||
return redirect(url_for('admin._login'))
|
||||
|
||||
@admin.route('/register/', methods=['GET','POST'])
|
||||
@disable_if_logged_in
|
||||
def _register():
|
||||
from ..models.user import User
|
||||
form = Register()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
new_user = User()
|
||||
new_user.set_username(request.form.get('username').lower())
|
||||
new_user.set_email(request.form.get('email').lower())
|
||||
success, message = new_user.register(password=request.form.get('password'))
|
||||
if success:
|
||||
flash(message=f'{message} Please log in to continue.', category='success')
|
||||
session['remembered_username'] = request.form.get('username').lower()
|
||||
return jsonify({'success': message}), 200
|
||||
flash(message=message, category='error')
|
||||
return jsonify({'error': message}), 401
|
||||
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
return render_template('admin/auth/register.html', form=form)
|
||||
|
||||
@admin.route('/reset/', methods=['GET','POST'])
|
||||
def _reset():
|
||||
form = ResetPassword()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
user = None
|
||||
users = User.query.all()
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username'):
|
||||
user = _user
|
||||
break
|
||||
if not user: return jsonify({'error': 'The user account does not exist.'}), 400
|
||||
if not user.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400
|
||||
return user.reset_password()
|
||||
errors = [*form.username.errors, *form.email.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
token = request.args.get('token')
|
||||
if token:
|
||||
user = User.query.filter_by(reset_token=token).first()
|
||||
if not user: return redirect(url_for('admin._reset'))
|
||||
verification_token = user.verification_token
|
||||
user.clear_reset_tokens()
|
||||
if request.args.get('verification') == verification_token:
|
||||
form = UpdatePassword()
|
||||
return render_template('/auth/update_password.html', form=form, user=user.id)
|
||||
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error')
|
||||
|
||||
return render_template('/admin/auth/reset.html', form=form)
|
||||
|
||||
@admin.route('/update_password/', methods=['POST'])
|
||||
def _update_password():
|
||||
form = UpdatePassword()
|
||||
if form.validate_on_submit():
|
||||
user = request.form.get('user')
|
||||
user = User.query.filter_by(id=user).first()
|
||||
user.update(password=request.form.get('password'))
|
||||
session['remembered_username'] = user.get_username()
|
||||
flash('Your password has been reset.', 'success')
|
||||
return jsonify({'success':'Your password has been reset'}), 200
|
||||
errors = [*form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 401
|
||||
|
||||
@admin.route('/settings/users/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _users():
|
||||
form = CreateUser()
|
||||
users = User.query.all()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
password = secrets.token_hex(12) if not password else password
|
||||
new_user = User()
|
||||
new_user.set_username(request.form.get('username').lower())
|
||||
new_user.set_email(request.form.get('email'))
|
||||
success, message = new_user.register(notify=request.form.get('notify'), password=password)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 401
|
||||
errors = [*form.username.errors, *form.email.errors, *form.password.errors]
|
||||
return jsonify({ 'error': errors}), 401
|
||||
return render_template('/admin/settings/users.html', form = form, users = users)
|
||||
|
||||
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _delete_user(id:str):
|
||||
user = User.query.filter_by(id=id).first()
|
||||
form = DeleteUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if id == current_user.id: return jsonify({'error': 'Cannot delete your own account.'}), 400
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
if not current_user.verify_password(password): return jsonify({'error': 'The password you entered is incorrect.'}), 401
|
||||
success, message = user.delete(notify=request.form.get('notify'))
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
errors = form.password.errors
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
if id == current_user.id:
|
||||
flash('Cannot delete your own user account.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user)
|
||||
|
||||
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _update_user(id:str):
|
||||
user = User.query.filter_by(id=id).first()
|
||||
form = UpdateUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if form.validate_on_submit():
|
||||
if not user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
||||
success, message = user.update(
|
||||
password = request.form.get('password'),
|
||||
email = request.form.get('email'),
|
||||
notify = request.form.get('notify')
|
||||
)
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
errors = [*form.confirm_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
return render_template('/admin/settings/update_user.html', form=form, id = id, user = user)
|
||||
|
||||
@admin.route('/settings/questions/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _questions():
|
||||
form = UploadData()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
upload = form.data_file.data
|
||||
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
|
||||
if not validate_json(upload): return jsonify({'error': 'The data in the file is invalid.'}), 400 # TODO Perhaps make a more complex validation script
|
||||
new_dataset = Dataset()
|
||||
success, message = new_dataset.create(
|
||||
upload = upload,
|
||||
default = request.form.get('default')
|
||||
)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
errors = form.data_file.errors
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
data = Dataset.query.all()
|
||||
return render_template('/admin/settings/questions.html', form=form, data=data)
|
||||
|
||||
@admin.route('/settings/questions/edit/', methods=['POST'])
|
||||
@login_required
|
||||
def _edit_questions():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if action not in ['defailt', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
if action == 'delete': success, message = dataset.delete()
|
||||
elif action == 'default': success, message = dataset.make_default()
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/tests/<string:filter>/', methods=['GET'])
|
||||
@admin.route('/tests/', methods=['GET'])
|
||||
@login_required
|
||||
def _tests(filter:str=None):
|
||||
datasets = Dataset.query.all()
|
||||
tests = None
|
||||
_tests = Test.query.all()
|
||||
form = None
|
||||
now = datetime.now()
|
||||
if not datasets:
|
||||
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
|
||||
return redirect(url_for('admin._questions'))
|
||||
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||
if filter == 'create':
|
||||
form = CreateTest()
|
||||
form.time_limit.choices = get_time_options()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
form.time_limit.default='none'
|
||||
form.process()
|
||||
display_title = ''
|
||||
error_none = ''
|
||||
if filter in [None, '', 'active']:
|
||||
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
|
||||
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.end_date < now ]
|
||||
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 > now]
|
||||
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', form = form, tests=tests, display_title=display_title, error_none=error_none, filter=filter)
|
||||
|
||||
@admin.route('/tests/create/', methods=['POST'])
|
||||
@login_required
|
||||
def _create_test():
|
||||
form = CreateTest()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
form.time_limit.choices = get_time_options()
|
||||
if form.validate_on_submit():
|
||||
new_test = Test()
|
||||
new_test.start_date = request.form.get('start_date')
|
||||
new_test.start_date = datetime.strptime(new_test.start_date, '%Y-%m-%dT%H:%M')
|
||||
new_test.end_date = request.form.get('expiry_date')
|
||||
new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%dT%H:%M')
|
||||
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
|
||||
dataset = request.form.get('dataset')
|
||||
new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||
success, message = new_test.create()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
else:
|
||||
errors = [*form.start_date.errors, *form.expiry_date.errors, *form.time_limit.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@admin.route('/tests/edit/', methods=['POST'])
|
||||
@login_required
|
||||
def _edit_test():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
if action == 'delete': success, message = test.delete()
|
||||
if action == 'start': success, message = test.start()
|
||||
if action == 'end': success, message = test.end()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/test/<string:id>/', methods=['GET','POST'])
|
||||
@login_required
|
||||
def _view_test(id:str=None):
|
||||
form = AddTimeAdjustment()
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
if request.method == 'POST':
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
if form.validate_on_submit():
|
||||
time = int(request.form.get('time'))
|
||||
success, message = test.add_adjustment(time)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return jsonify({'error': form.time.errors }), 400
|
||||
if not test:
|
||||
flash('Invalid test ID.', 'error')
|
||||
return redirect(url_for('admin._tests', filter='active'))
|
||||
return render_template('/admin/test.html', test = test, form = form)
|
||||
|
||||
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
|
||||
@login_required
|
||||
def _delete_adjustment(id:str=None):
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
user_code = request.get_json()['user_code'].lower()
|
||||
success, message = test.remove_adjustment(user_code)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/results/')
|
||||
@login_required
|
||||
def _view_entries():
|
||||
entries = Entry.query.all()
|
||||
return render_template('/admin/results.html', entries = entries)
|
||||
|
||||
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
||||
@login_required
|
||||
def _view_entry(id:str=None):
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if request.method == 'POST':
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
action = request.get_json()['action']
|
||||
if action not in ['validate', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
if action == 'validate':
|
||||
success, message = entry.validate()
|
||||
if action == 'delete':
|
||||
success, message = entry.delete()
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
entry.notify_result()
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}),400
|
||||
if not entry:
|
||||
flash('Invalid entry ID.', 'error')
|
||||
return redirect(url_for('admin._view_entries'))
|
||||
test = entry.test
|
||||
dataset = test.dataset
|
||||
dataset_path = dataset.get_file()
|
||||
with open(dataset_path, 'r') as _dataset:
|
||||
data = loads(_dataset.read())
|
||||
correct = get_correct_answers(dataset=data)
|
||||
answers = answer_options(dataset=data)
|
||||
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
|
||||
|
||||
@admin.route('/certificate/',methods=['POST'])
|
||||
@login_required
|
||||
def _generate_certificate():
|
||||
from main import db
|
||||
id = request.get_json()['id']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return render_template('/admin/components/certificate.html', entry = entry)
|
@ -1,66 +0,0 @@
|
||||
from ..models import Dataset, Entry
|
||||
from ..tools.test import evaluate_answers, generate_questions
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from json import loads
|
||||
|
||||
api = Blueprint(
|
||||
name='api',
|
||||
import_name=__name__
|
||||
)
|
||||
|
||||
@api.route('/questions/', methods=['POST'])
|
||||
def _fetch_questions():
|
||||
id = request.get_json()['id']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
|
||||
test = entry.test
|
||||
user_code = entry.user_code
|
||||
time_limit = test.time_limit
|
||||
time_adjustment = 0
|
||||
if time_limit:
|
||||
_time_limit = int(time_limit)
|
||||
if user_code:
|
||||
time_adjustment = test.adjustments[user_code]
|
||||
_time_limit += time_adjustment
|
||||
end_delta = timedelta(minutes=_time_limit)
|
||||
end_time = datetime.utcnow() + end_delta
|
||||
else:
|
||||
end_time = None
|
||||
entry.start()
|
||||
dataset = test.dataset
|
||||
success, message = dataset.check_file()
|
||||
if not success: return jsonify({'error': message}), 500
|
||||
data_path = dataset.get_file()
|
||||
with open(data_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
|
||||
}), 200
|
||||
|
||||
@api.route('/submit/', methods=['POST'])
|
||||
def _submit_quiz():
|
||||
id = request.get_json()['id']
|
||||
answers = request.get_json()['answers']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
|
||||
test = entry.test
|
||||
dataset = test.dataset
|
||||
success, message = dataset.check_file()
|
||||
if not success: return jsonify({'error': message}), 500
|
||||
data_path = dataset.get_file()
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
result = evaluate_answers(answers=answers, key=data)
|
||||
entry.complete(answers=answers, result=result)
|
||||
return jsonify({
|
||||
'success': 'Your submission has been processed. Redirecting you to receive your results.',
|
||||
'id': id
|
||||
}), 200
|
||||
|
@ -1,47 +0,0 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
if not os.getenv('DATA'):
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('../.env')
|
||||
|
||||
class Config(object):
|
||||
APP_HOST = '0.0.0.0'
|
||||
DATA = os.getenv('DATA')
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
SERVER_NAME = os.getenv('SERVER_NAME')
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(DATA)}/database.db'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
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'))
|
||||
|
||||
class ProductionConfig(Config):
|
||||
pass
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
APP_HOST = '127.0.0.1'
|
||||
DEBUG = True
|
||||
SESSION_COOKIE_SECURE = False
|
||||
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
|
@ -1,5 +0,0 @@
|
||||
from config import Config
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
|
||||
data = Path(Config.DATA)
|
@ -1,4 +0,0 @@
|
||||
from .entry import Entry
|
||||
from .test import Test
|
||||
from .user import User
|
||||
from .dataset import Dataset
|
@ -1,83 +0,0 @@
|
||||
from ..data import data
|
||||
from ..modules import db
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import flash
|
||||
from flask_login import current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from datetime import datetime
|
||||
from json import dump, loads
|
||||
from os import path, remove
|
||||
from uuid import uuid4
|
||||
|
||||
class Dataset(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
tests = db.relationship('Test', backref='dataset')
|
||||
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||
date = db.Column(db.DateTime, nullable=False)
|
||||
default = db.Column(db.Boolean, default=False, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Dataset {self.id}> was added.'
|
||||
|
||||
@property
|
||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||
|
||||
generate_id.setter
|
||||
def generate_id(self): self.id = uuid4().hex
|
||||
|
||||
def make_default(self):
|
||||
for dataset in Dataset.query.all():
|
||||
dataset.default = False
|
||||
self.default = True
|
||||
db.session.commit()
|
||||
write('system.log', f'Dataset {self.id} set as default by {current_user.get_username()}.')
|
||||
flash(message='Dataset set as default.', category='success')
|
||||
return True, f'Dataset set as default.'
|
||||
|
||||
def delete(self):
|
||||
if self.default:
|
||||
message = 'Cannot delete the default dataset.'
|
||||
flash(message, 'error')
|
||||
return False, message
|
||||
if Dataset.query.all().count() == 1:
|
||||
message = 'Cannot delete the only dataset.'
|
||||
flash(message, 'error')
|
||||
return False, message
|
||||
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
remove(file_path)
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
return True, 'Dataset deleted.'
|
||||
|
||||
def create(self, upload, default:bool=False):
|
||||
self.generate_id()
|
||||
timestamp = datetime.now()
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
upload.stream.seek(0)
|
||||
questions = loads(upload.read())
|
||||
with open(file_path, 'w') as file:
|
||||
dump(questions, file, indent=2)
|
||||
self.date = timestamp
|
||||
self.creator = current_user
|
||||
if default: self.make_default()
|
||||
write('system.log', f'New dataset {self.id} added by {current_user.get_username()}.')
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
return True, 'Dataset uploaded.'
|
||||
|
||||
def check_file(self):
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
if not path.isfile(file_path): return False, 'Data file is missing.'
|
||||
return True, 'Data file found.'
|
||||
|
||||
def get_file(self):
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
return file_path
|
@ -1,177 +0,0 @@
|
||||
from ..modules import db, mail
|
||||
from ..tools.forms import JsonEncodedDict
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
from .test import Test
|
||||
|
||||
from flask_login import current_user
|
||||
from flask_mail import Message
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
class Entry(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
first_name = db.Column(db.String(128), nullable=False)
|
||||
surname = db.Column(db.String(128), nullable=False)
|
||||
email = db.Column(db.String(128), nullable=False)
|
||||
club = db.Column(db.String(128), nullable=True)
|
||||
test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
|
||||
user_code = db.Column(db.String(6), nullable=True)
|
||||
start_time = db.Column(db.DateTime, nullable=True)
|
||||
end_time = db.Column(db.DateTime, nullable=True)
|
||||
status = db.Column(db.String(16), nullable=True)
|
||||
valid = db.Column(db.Boolean, default=True, nullable=True)
|
||||
answers = db.Column(JsonEncodedDict, nullable=True)
|
||||
result = db.Column(JsonEncodedDict, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.'
|
||||
|
||||
@property
|
||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||
|
||||
generate_id.setter
|
||||
def generate_id(self): self.id = uuid4().hex
|
||||
|
||||
@property
|
||||
def set_first_name(self): raise AttributeError('set_first_name is not a readable attribute.')
|
||||
|
||||
set_first_name.setter
|
||||
def set_first_name(self, name:str): self.first_name = encrypt(name)
|
||||
|
||||
def get_first_name(self): return decrypt(self.first_name)
|
||||
|
||||
@property
|
||||
def set_surname(self): raise AttributeError('set_first_name is not a readable attribute.')
|
||||
|
||||
set_surname.setter
|
||||
def set_surname(self, name:str): self.surname = encrypt(name)
|
||||
|
||||
def get_surname(self): return decrypt(self.surname)
|
||||
|
||||
@property
|
||||
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
|
||||
|
||||
set_email.setter
|
||||
def set_email(self, email:str): self.email = encrypt(email)
|
||||
|
||||
def get_email(self): return decrypt(self.email)
|
||||
|
||||
@property
|
||||
def set_club(self): raise AttributeError('set_club is not a readable attribute.')
|
||||
|
||||
set_club.setter
|
||||
def set_club(self, club:str): self.club = encrypt(club)
|
||||
|
||||
def get_club(self): return decrypt(self.club)
|
||||
|
||||
def ready(self):
|
||||
self.generate_id()
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
write('tests.log', f'New test ready for {self.get_first_name()} {self.get_surname()}.')
|
||||
return True, f'Test ready.'
|
||||
|
||||
def start(self):
|
||||
self.start_time = datetime.now()
|
||||
self.status = 'started'
|
||||
write('tests.log', f'Test started by {self.get_first_name()} {self.get_surname()}.')
|
||||
db.session.commit()
|
||||
return True, f'New test started with id {self.id}.'
|
||||
|
||||
def complete(self, answers:dict=None, result:dict=None):
|
||||
self.end_time = datetime.now()
|
||||
self.answers = answers
|
||||
self.result = result
|
||||
write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.')
|
||||
delta = timedelta(minutes=int(0 if self.test.time_limit is None else self.test.time_limit)+1)
|
||||
if not self.test.time_limit or self.end_time <= self.start_time + delta:
|
||||
self.status = 'completed'
|
||||
self.valid = True
|
||||
else:
|
||||
self.status = 'late'
|
||||
self.valid = False
|
||||
db.session.commit()
|
||||
return True, f'Test entry completed for id {self.id}.'
|
||||
|
||||
def validate(self):
|
||||
if self.valid: return False, f'The entry is already valid.'
|
||||
if self.status == 'started': return False, 'The entry is still pending.'
|
||||
self.valid = True
|
||||
self.status = 'completed'
|
||||
db.session.commit()
|
||||
write('system.log', f'The entry {self.id} has been validated by {current_user.get_username()}.')
|
||||
return True, f'The entry {self.id} has been validated.'
|
||||
|
||||
def delete(self):
|
||||
id = self.id
|
||||
name = f'{self.get_first_name()} {self.get_surname()}'
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
write('system.log', f'The entry {id} by {name} has been deleted by {current_user.get_username()}.')
|
||||
return True, 'Entry deleted.'
|
||||
|
||||
def notify_result(self):
|
||||
score = round(100*self.result['score']/self.result['max'])
|
||||
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in self.result['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]
|
||||
revision_plain = ''
|
||||
revision_html = ''
|
||||
if self.result['grade'] == 'pass':
|
||||
flavour_text = """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 self.result['grade'] == 'merit':
|
||||
flavour_text = """Congratulations on achieving a merit in the refereeing exam. We are delighted that members of our community work so hard with refereeing.
|
||||
"""
|
||||
elif self.result['grade'] == 'fail':
|
||||
flavour_text = """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.
|
||||
"""
|
||||
revision_plain = f"""Based on your answers, we would also suggest you brush up on the following topics for your next attempt:\n\n
|
||||
{','.join(tag_output)}\n\n
|
||||
"""
|
||||
revision_html = f"""<p>Based on your answers, we would also suggest you brush up on the following topics for your next attempt:</p>
|
||||
<ul>
|
||||
<li>{'</li><li>'.join(tag_output)}</li>
|
||||
</ul>
|
||||
"""
|
||||
email = Message(
|
||||
subject='RefTest | SKA Refereeing Theory Exam Results',
|
||||
recipients=[self.get_email()],
|
||||
body=f"""
|
||||
SKA Refereeing Theory Exam
|
||||
Candidate Results
|
||||
Dear {self.get_first_name()},
|
||||
This email is to confirm that you have taken the SKA Refereeing Theory Exam. Your submission has been evaluated and your results are as follows:
|
||||
{self.get_surname()}, {self.get_first_name()}
|
||||
Email Address: {self.get_email()}
|
||||
{f'Club: {self.get_club()}' if self.club else ''}
|
||||
Date of Exam: {self.end_time.strftime('%d %b %Y')}
|
||||
Score: {score}%
|
||||
Grade: {self.result['grade']}
|
||||
{flavour_text}
|
||||
{revision_plain}
|
||||
Thank you for taking the time to become a qualified referee.
|
||||
Best wishes,
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<h1>SKA Refereeing Theory Exam</h1>
|
||||
<h2>Candidate Results</h2>
|
||||
<p>Dear {self.get_first_name()},</p>
|
||||
<p>This email is to confirm that you have taken the SKA Refereeing Theory Exam. Your submission has been evaluated and your results are as follows:</p>
|
||||
<h3>{self.get_surname()}, {self.get_first_name()}</h3>
|
||||
<p><strong>Email Address</strong>: {self.get_email()}</p>
|
||||
{f'<p><strong>Club</strong>: {self.get_club()}</p>' if self.club else ''}
|
||||
<h1>{score}%</h1>
|
||||
<h2>{self.result['grade']}</h2>
|
||||
<p>{flavour_text}</p>
|
||||
{revision_html}
|
||||
<p>Thank you for taking the time to become a qualified referee.</p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>Best wishes, <br/> SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
@ -1,111 +0,0 @@
|
||||
from ..modules import db
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.forms import JsonEncodedDict
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask_login import current_user
|
||||
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
|
||||
class Test(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
code = db.Column(db.String(36), nullable=False)
|
||||
start_date = db.Column(db.DateTime, nullable=True)
|
||||
end_date = db.Column(db.DateTime, nullable=True)
|
||||
time_limit = db.Column(db.Integer, nullable=True)
|
||||
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
|
||||
adjustments = db.Column(JsonEncodedDict, nullable=True)
|
||||
entries = db.relationship('Entry', backref='test')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Test with code {self.get_code()} was created by {current_user.get_username()}.>'
|
||||
|
||||
@property
|
||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||
|
||||
generate_id.setter
|
||||
def generate_id(self): self.id = uuid4().hex
|
||||
|
||||
@property
|
||||
def generate_code(self): raise AttributeError('generate_code is not a readable attribute.')
|
||||
|
||||
generate_code.setter
|
||||
def generate_code(self): self.code = secrets.token_hex(6).lower()
|
||||
|
||||
def get_code(self):
|
||||
code = self.code.upper()
|
||||
return '—'.join([code[:4], code[4:8], code[8:]])
|
||||
|
||||
def create(self):
|
||||
self.generate_id()
|
||||
self.generate_code()
|
||||
self.creator = current_user
|
||||
errors = []
|
||||
if self.start_date.date() < date.today():
|
||||
errors.append('The start date cannot be in the past.')
|
||||
if self.end_date.date() < date.today():
|
||||
errors.append('The expiry date cannot be in the past.')
|
||||
if self.end_date < self.start_date:
|
||||
errors.append('The expiry date cannot be before the start date.')
|
||||
if errors:
|
||||
return False, errors
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} created by {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been created.'
|
||||
|
||||
def delete(self):
|
||||
code = self.code
|
||||
if self.entries: return False, f'Cannot delete a test with submitted entries.'
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} has been deleted by {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been deleted.'
|
||||
|
||||
def start(self):
|
||||
now = datetime.now()
|
||||
if self.start_date.date() > now.date():
|
||||
self.start_date = now
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} has been started by {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been started.'
|
||||
return False, f'Test with code {self.get_code()} has already started.'
|
||||
|
||||
def end(self):
|
||||
now = datetime.now()
|
||||
if self.end_date >= now:
|
||||
self.end_date = now
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} ended by {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been ended.'
|
||||
return False, f'Test with code {self.get_code()} has already ended.'
|
||||
|
||||
def add_adjustment(self, time:int):
|
||||
adjustments = self.adjustments if self.adjustments is not None else {}
|
||||
code = secrets.token_hex(3).lower()
|
||||
adjustments[code] = time
|
||||
self.adjustments = adjustments
|
||||
db.session.commit()
|
||||
write('system.log', f'Time adjustment for {time} minutes with code {code} added to test {self.get_code()} by {current_user.get_username()}.')
|
||||
return True, f'Time adjustment for {time} minutes added to test {self.get_code()}. This can be accessed using the user code {code.upper()}.'
|
||||
|
||||
def remove_adjustment(self, code:str):
|
||||
if not self.adjustments: return False, f'There are no adjustments configured for test {self.get_code()}.'
|
||||
self.adjustments.pop(code)
|
||||
if not self.adjustments: self.adjustments = None
|
||||
db.session.commit()
|
||||
write('system.log', f'Time adjustment for with code {code} has been removed from test {self.get_code()} by {current_user.get_username()}.')
|
||||
return True, f'Time adjustment for with code {code} has been removed from test {self.get_code()}.'
|
||||
|
||||
def update(self, start_date:datetime=None, end_date:datetime=None, time_limit:int=None):
|
||||
if not start_date and not end_date and time_limit is None: return False, 'There were no changes requested.'
|
||||
if start_date: self.start_date = start_date
|
||||
if end_date: self.end_date = end_date
|
||||
if time_limit is not None: self.time_limit = time_limit
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} has been updated by user {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been updated by.'
|
@ -1,223 +0,0 @@
|
||||
from ..modules import db, mail
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import flash, jsonify, session
|
||||
from flask.helpers import url_for
|
||||
from flask_login import current_user, login_user, logout_user, UserMixin
|
||||
from flask_mail import Message
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
username = db.Column(db.String(128), nullable=False)
|
||||
password = db.Column(db.String(128), nullable=False)
|
||||
email = db.Column(db.String(128), nullable=False)
|
||||
reset_token = db.Column(db.String(20), nullable=True)
|
||||
verification_token = db.Column(db.String(20), nullable=True)
|
||||
tests = db.relationship('Test', backref='creator')
|
||||
datasets = db.relationship('Dataset', backref='creator')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<user {self.username}> was added with <id {self.id}>.'
|
||||
|
||||
@property
|
||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||
|
||||
generate_id.setter
|
||||
def generate_id(self): self.id = uuid4().hex
|
||||
|
||||
@property
|
||||
def set_username(self): raise AttributeError('set_username is not a readable attribute.')
|
||||
|
||||
set_username.setter
|
||||
def set_username(self, username:str): self.username = encrypt(username)
|
||||
|
||||
def get_username(self): return decrypt(self.username)
|
||||
|
||||
@property
|
||||
def set_password(self): raise AttributeError('set_password is not a readable attribute.')
|
||||
|
||||
set_password.setter
|
||||
def set_password(self, password:str): self.password = generate_password_hash(password, method="sha256")
|
||||
|
||||
def verify_password(self, password:str): return check_password_hash(self.password, password)
|
||||
|
||||
@property
|
||||
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
|
||||
|
||||
set_email.setter
|
||||
def set_email(self, email:str): self.email = encrypt(email)
|
||||
|
||||
def get_email(self): return decrypt(self.email)
|
||||
|
||||
def register(self, notify:bool=False, password:str=None):
|
||||
self.generate_id()
|
||||
users = User.query.all()
|
||||
for user in users:
|
||||
if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.'
|
||||
if user.get_email() == self.get_email(): return False, f'Email address {self.get_email()} already in use.'
|
||||
self.set_password(password=password)
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
write('users.log', f'User \'{self.get_username()}\' was created with id \'{self.id}\'.')
|
||||
if notify:
|
||||
email = Message(
|
||||
subject='RefTest | Registration Confirmation',
|
||||
recipients=[self.email],
|
||||
body=f"""
|
||||
Hello {self.get_username()},\n\n
|
||||
You have been registered as an administrator on the SKA RefTest App!\n\n
|
||||
You can access your account using the username '{self.get_username()}'\n\n
|
||||
Your password is as follows:\n\n
|
||||
{password}\n\n
|
||||
You can log in to the admin console via the following URL, where you can administer the test or change your password:\n\n
|
||||
{url_for('admin._home', _external=True)}\n\n
|
||||
Have a nice day!\n\n
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {self.get_username()},</p>
|
||||
<p>You have been registered as an administrator on the SKA RefTest App!</p>
|
||||
<p>You can access your account using the username '{self.get_username()}'</p>
|
||||
<p>Your password is as follows:</p>
|
||||
<strong>{password}</strong>
|
||||
<p>You can log in to the admin console via the following URL, where you can administer the test or change your password:</p>
|
||||
<p><a href='{url_for('admin._home', _external=True)}'>{url_for('admin._home', _external=True)}</a></p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
return True, f'User {self.get_username()} was created successfully.'
|
||||
|
||||
def login(self, remember:bool=False):
|
||||
login_user(self, remember = remember)
|
||||
write('users.log', f'User \'{self.get_username()}\' has logged in.')
|
||||
flash(message=f'Welcome {self.get_username()}', category='success')
|
||||
|
||||
def logout(self):
|
||||
session['remembered_username'] = self.get_username()
|
||||
logout_user()
|
||||
write('users.log', f'User \'{self.get_username()}\' has logged out.')
|
||||
flash(message='You have successfully logged out.', category='success')
|
||||
|
||||
def reset_password(self):
|
||||
new_password = secrets.token_hex(12)
|
||||
self.set_password(new_password)
|
||||
self.reset_token = secrets.token_urlsafe(16)
|
||||
self.verification_token = secrets.token_urlsafe(16)
|
||||
db.session.commit()
|
||||
email = Message(
|
||||
subject='RefTest | Password Reset',
|
||||
recipients=[self.get_email()],
|
||||
body=f"""
|
||||
Hello {self.get_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
|
||||
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._reset', token = self.reset_token, verification = self.verification_token, _external = True)}\n\n
|
||||
Hopefully, this should enable access to your account once again.\n\n
|
||||
Have a nice day!\n\n
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {self.get_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>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. Copy and paste the following link in a web browser. Please note that this token is only valid once:</p>
|
||||
<p><a href='{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}'>{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}</a></p>
|
||||
<p>Hopefully, this should enable access to your account once again.</p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
print('Password', new_password)
|
||||
print('Reset Token', self.reset_token)
|
||||
print('Verification Token', self.verification_token)
|
||||
print('Reset Link', f'{url_for("admin._reset", token=self.reset_token, verification=self.verification_token, _external=True)}')
|
||||
return jsonify({'success': 'Your password reset link has been generated.'}), 200
|
||||
|
||||
def clear_reset_tokens(self):
|
||||
self.reset_token = self.verification_token = None
|
||||
db.session.commit()
|
||||
|
||||
def delete(self, notify:bool=False):
|
||||
username = self.get_username()
|
||||
email_address = self.get_email()
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
message = f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.'
|
||||
write('users.log', message)
|
||||
if notify:
|
||||
email = Message(
|
||||
subject='RefTest | Account Deletion',
|
||||
recipients=[email_address],
|
||||
bcc=[current_user.get_email()],
|
||||
body=f"""
|
||||
Hello {username},\n\n
|
||||
Your administrator account for the SKA RefTest App, as well as all data associated with the account, have been deleted by {current_user.get_username()}.\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!\n\n
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {username},</p>
|
||||
<p>Your administrator account for the SKA RefTest App, as well as all data associated with the account, have been deleted by {current_user.get_username()}.</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>
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
return True, message
|
||||
|
||||
def update(self, password:str=None, email:str=None, notify:bool=False):
|
||||
if not password and not email: return False, 'There were no changes requested.'
|
||||
if password: self.set_password(password)
|
||||
old_email = self.get_email()
|
||||
if email: self.set_email(email)
|
||||
db.session.commit()
|
||||
write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.')
|
||||
if notify:
|
||||
message = Message(
|
||||
subject='RefTest | Account Update',
|
||||
recipients=[email],
|
||||
bcc=[old_email,current_user.get_email()],
|
||||
body=f"""
|
||||
Hello {self.get_username()},\n\n
|
||||
Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.\n\n
|
||||
Your new account details are as follows:\n\n
|
||||
Email: {email}\n
|
||||
Password: {password if password else '<same as old>'}\n\n
|
||||
You can update your email address and password by logging in to the admin console using the following URL:\n\n
|
||||
{url_for('admin._home', _external=True)}\n\n
|
||||
Have a nice day!\n\n
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {self.get_username()},</p>
|
||||
<p>Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.</p>
|
||||
<p>Your new account details are as follows:</p>
|
||||
<p>Email: {email} <br/> Password: <strong>{password if password else '<same as old>'}</strong></p>
|
||||
<p>You can update your email address and password by logging in to the admin console using the following URL:</p>
|
||||
<p><a href='{url_for('admin._home', _external=True)}'>{url_for('admin._home', _external=True)}</a></p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(message)
|
||||
return True, f'Account {self.get_username()} has been updated.'
|
@ -1,10 +0,0 @@
|
||||
from flask_bootstrap import Bootstrap
|
||||
bootstrap = Bootstrap()
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
csrf = CSRFProtect()
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
db = SQLAlchemy()
|
||||
from flask_login import LoginManager
|
||||
login_manager = LoginManager()
|
||||
from flask_mail import Mail
|
||||
mail = Mail()
|
@ -1,80 +0,0 @@
|
||||
from ..forms.quiz import StartQuiz
|
||||
from ..models import Entry, Test
|
||||
from ..tools.test import redirect_if_started
|
||||
|
||||
from flask import abort, Blueprint, jsonify, redirect, render_template, request, session
|
||||
from flask.helpers import flash, url_for
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
quiz = Blueprint(
|
||||
name='quiz',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static',
|
||||
static_url_path='/quiz/static'
|
||||
)
|
||||
|
||||
@quiz.route('/')
|
||||
@quiz.route('/home/')
|
||||
@redirect_if_started
|
||||
def _home():
|
||||
return render_template('/quiz/index.html')
|
||||
|
||||
@quiz.route('/instructions/')
|
||||
def _instructions():
|
||||
return render_template('/quiz/instructions.html')
|
||||
|
||||
@quiz.route('/start/', methods=['GET', 'POST'])
|
||||
def _start():
|
||||
form = StartQuiz()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = Entry()
|
||||
entry.set_first_name(request.form.get('first_name'))
|
||||
entry.set_surname(request.form.get('surname'))
|
||||
entry.set_club(request.form.get('club'))
|
||||
entry.set_email(request.form.get('email'))
|
||||
code = request.form.get('test_code').replace('—', '').lower()
|
||||
test = Test.query.filter_by(code=code).first()
|
||||
entry.test = test
|
||||
entry.user_code = request.form.get('user_code')
|
||||
entry.user_code = None if entry.user_code == '' else entry.user_code.lower()
|
||||
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400
|
||||
if entry.user_code and entry.user_code not in test.adjustments: return jsonify({'error': f'The user code you entered is not valid.'}), 400
|
||||
if test.end_date < datetime.now(): return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y %H:%M")}.'}), 400
|
||||
if test.start_date > datetime.now(): return jsonify({'error': f'The exam has not yet opened. Your exam code will be valid from {test["start_date"].strftime("%d %b %Y %H:%M")}.'}), 400
|
||||
success, message = entry.ready()
|
||||
if success:
|
||||
session['id'] = entry.id
|
||||
return jsonify({
|
||||
'success': 'Received and validated test and/or user code. Redirecting to test client.',
|
||||
'id': entry.id
|
||||
}), 200
|
||||
return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400
|
||||
errors = [*form.test_code.errors, *form.user_code.errors, *form.first_name.errors, *form.surname.errors, *form.email.errors, *form.club.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
return render_template('/quiz/start_quiz.html', form = form)
|
||||
|
||||
@quiz.route('/quiz/')
|
||||
def _quiz():
|
||||
id = session.get('id')
|
||||
if not id or not Entry.query.filter_by(id=id).first():
|
||||
flash('Your session was not recognised. Please sign in to the quiz again.', 'error')
|
||||
session.pop('id', None)
|
||||
return redirect(url_for('quiz._start'))
|
||||
return render_template('/quiz/client.html')
|
||||
|
||||
@quiz.route('/result/')
|
||||
def _result():
|
||||
id = session.get('id')
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if not entry: return abort(404)
|
||||
session.pop('id',None)
|
||||
score = round(100*entry.result['score']/entry.result['max'])
|
||||
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in entry.result['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 not entry.status == 'late':
|
||||
entry.notify_result()
|
||||
return render_template('/quiz/result.html', entry=entry, score=score, tag_output=tag_output)
|
@ -1,218 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 84 KiB |
2
ref-test/app/root/js/jquery-3.6.0.min.js
vendored
@ -1,86 +0,0 @@
|
||||
$(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 = `/quiz/`;
|
||||
},
|
||||
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: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response);
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
})
|
@ -1,78 +0,0 @@
|
||||
<!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 "components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% block navbar %}
|
||||
{% include "components/navbar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container quiz-container">
|
||||
{% block top_alerts %}
|
||||
{% include "components/server-alerts.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<footer class="container site-footer">
|
||||
{% include "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>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||
></script>
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
@ -1,3 +0,0 @@
|
||||
<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>
|
@ -1,14 +0,0 @@
|
||||
<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>
|
@ -1,17 +0,0 @@
|
||||
<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" />
|
@ -1,43 +0,0 @@
|
||||
{% 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 %}
|
@ -1,24 +0,0 @@
|
||||
from .data import load
|
||||
from ..models import User
|
||||
|
||||
from flask import abort, redirect
|
||||
from flask.helpers import flash, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
from functools import wraps
|
||||
|
||||
def require_account_creation(function):
|
||||
@wraps(function)
|
||||
def wrapper(*args, **kwargs):
|
||||
if User.query.count() == 0:
|
||||
flash('Please register a user account.', 'alert')
|
||||
return redirect(url_for('admin._register'))
|
||||
return function(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def disable_if_logged_in(function):
|
||||
@wraps(function)
|
||||
def wrapper(*args, **kwargs):
|
||||
if current_user.is_authenticated: return abort(404)
|
||||
return function(*args, **kwargs)
|
||||
return wrapper
|
@ -1,35 +0,0 @@
|
||||
from ..data import data as data_dir
|
||||
|
||||
import json
|
||||
from random import shuffle
|
||||
|
||||
def load(filename:str):
|
||||
with open(f'./{data_dir}/{filename}') as file:
|
||||
return json.load(file)
|
||||
|
||||
def save(data:dict, filename:str):
|
||||
with open(f'./{data_dir}/{filename}', 'w') as file:
|
||||
json.dump(data, file, indent=4)
|
||||
|
||||
def check_is_json(file):
|
||||
if not '.' in file.filename or not file.filename.rsplit('.',1)[-1] == 'json': return False
|
||||
return True
|
||||
|
||||
def validate_json(file):
|
||||
file.stream.seek(0)
|
||||
data = json.loads(file.read())
|
||||
if not isinstance(data, list): return False
|
||||
return True
|
||||
|
||||
def randomise_list(list:list):
|
||||
_list = list.copy()
|
||||
shuffle(_list)
|
||||
return(_list)
|
||||
|
||||
def get_tag_list(dataset:list):
|
||||
output = []
|
||||
for block in dataset:
|
||||
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
|
@ -1,19 +0,0 @@
|
||||
from ..data import data
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
def load_key():
|
||||
with open(f'./{data}/.encryption.key', 'rb') as keyfile: return keyfile.read()
|
||||
|
||||
def decrypt(input:str):
|
||||
encryption_key = load_key()
|
||||
fernet = Fernet(encryption_key)
|
||||
input = input.encode()
|
||||
output = fernet.decrypt(input)
|
||||
return output.decode()
|
||||
|
||||
def encrypt(input:str):
|
||||
encryption_key = load_key()
|
||||
fernet = Fernet(encryption_key)
|
||||
input = input.encode()
|
||||
output = fernet.encrypt(input)
|
||||
return output.decode()
|
@ -1,56 +0,0 @@
|
||||
|
||||
from ..modules import db
|
||||
|
||||
from wtforms.validators import ValidationError
|
||||
|
||||
import json
|
||||
from sqlalchemy.ext import mutable
|
||||
|
||||
class JsonEncodedDict(db.TypeDecorator):
|
||||
"""Enables JSON storage by encoding and decoding on the fly."""
|
||||
impl = db.Text
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return '{}'
|
||||
else:
|
||||
return json.dumps(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return {}
|
||||
else:
|
||||
return json.loads(value)
|
||||
|
||||
mutable.MutableDict.associate_with(JsonEncodedDict)
|
||||
|
||||
def value(min:int=0, max:int=None):
|
||||
if not max:
|
||||
message = f'Value must be greater than {min}.'
|
||||
else:
|
||||
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
|
||||
|
||||
def get_time_options():
|
||||
time_options = [
|
||||
('none', 'None'),
|
||||
('60', '1 hour'),
|
||||
('90', '1 hour 30 minutes'),
|
||||
('120', '2 hours')
|
||||
]
|
||||
return time_options
|
||||
|
||||
def get_dataset_choices():
|
||||
from ..models import Dataset
|
||||
datasets = Dataset.query.all()
|
||||
dataset_choices = []
|
||||
for dataset in datasets:
|
||||
label = dataset.date.strftime('%Y%m%d%H%M%S')
|
||||
label = f'{label} (Default)' if dataset.default else label
|
||||
choice = (dataset.id, label)
|
||||
dataset_choices.append(choice)
|
||||
return dataset_choices
|
@ -1,10 +0,0 @@
|
||||
from ..data import data
|
||||
from datetime import datetime
|
||||
|
||||
def read(filename:str):
|
||||
with open(f'./{data}/logs/{filename}') as file:
|
||||
return file.readlines()
|
||||
|
||||
def write(filename:str, message:str):
|
||||
with open(f'./{data}/logs/{filename}', 'a+') as file:
|
||||
file.write(f'{datetime.now().strftime("%Y-%m-%d-%X")}: {message}\n')
|
@ -1,147 +0,0 @@
|
||||
from .data import randomise_list
|
||||
from ..models import Entry
|
||||
|
||||
from flask import redirect, request, session
|
||||
from flask.helpers import url_for
|
||||
|
||||
from functools import wraps
|
||||
|
||||
def parse_test_code(code):
|
||||
return code.replace('—', '').lower()
|
||||
|
||||
def generate_questions(dataset:list):
|
||||
output = []
|
||||
for block in randomise_list(dataset):
|
||||
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([*enumerate(block['options'])])
|
||||
else: question['options'] = [*enumerate(block['options'])]
|
||||
output.append(question)
|
||||
elif 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([*enumerate(_question['options'])])
|
||||
else: question['options'] = [*enumerate(_question['options'])]
|
||||
output.append(question)
|
||||
return output
|
||||
|
||||
def evaluate_answers(answers:dict, key:list):
|
||||
score = 0
|
||||
max = 0
|
||||
tags = {}
|
||||
for block in key:
|
||||
if block['type'] == 'question':
|
||||
max += 1
|
||||
q_no = block['q_no']
|
||||
if str(q_no) in answers:
|
||||
submitted_answer = int(answers[str(q_no)])
|
||||
if submitted_answer == block['correct']:
|
||||
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
|
||||
else:
|
||||
for tag in block['tags']:
|
||||
if tag not in tags:
|
||||
tags[tag] = {
|
||||
'scored': 0,
|
||||
'max': 1
|
||||
}
|
||||
else: tags[tag]['max'] += 1
|
||||
elif block['type'] == 'block':
|
||||
for question in block['questions']:
|
||||
max += 1
|
||||
q_no = question['q_no']
|
||||
if str(q_no) in answers:
|
||||
submitted_answer = int(answers[str(q_no)])
|
||||
if submitted_answer == question['correct']:
|
||||
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
|
||||
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_correct_answers(dataset:list):
|
||||
output = {}
|
||||
for block in dataset:
|
||||
if block['type'] == 'question':
|
||||
output[str(block['q_no'])] = block['correct']
|
||||
if block['type'] == 'block':
|
||||
for question in block['questions']:
|
||||
output[str(question['q_no'])] = question['correct']
|
||||
return output
|
||||
|
||||
def redirect_if_started(function):
|
||||
@wraps(function)
|
||||
def wrapper(*args, **kwargs):
|
||||
id = session.get('id')
|
||||
if request.method == 'GET' and id and Entry.query.filter_by(id=id).first():
|
||||
return redirect(url_for('quiz._quiz'))
|
||||
return function(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def answer_options(dataset:list):
|
||||
output = []
|
||||
for block in dataset:
|
||||
if block['type'] == 'question':
|
||||
question = block['options'].copy()
|
||||
output.append(question)
|
||||
elif block['type'] == 'block':
|
||||
for _question in block['questions']:
|
||||
question = _question['options'].copy()
|
||||
output.append(question)
|
||||
return output
|
@ -1,30 +0,0 @@
|
||||
from .config import Config
|
||||
|
||||
from flask import Blueprint, redirect, request, render_template
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
views = Blueprint(
|
||||
name='views',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='root',
|
||||
)
|
||||
|
||||
@views.route('/privacy/')
|
||||
def _privacy():
|
||||
return render_template('privacy.html')
|
||||
|
||||
@views.route('/cookies/', methods=['POST'])
|
||||
def _cookie_consent():
|
||||
resp = redirect('/')
|
||||
resp.set_cookie(
|
||||
key='cookie_consent',
|
||||
value='true',
|
||||
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else None,
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else None,
|
||||
domain = f'{Config.SERVER_NAME}',
|
||||
secure = True
|
||||
)
|
||||
return resp
|
21
ref-test/common/blueprints.py
Normal file
@ -0,0 +1,21 @@
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, redirect, request
|
||||
|
||||
cookie_consent = Blueprint(
|
||||
'cookie_consent',
|
||||
__name__
|
||||
)
|
||||
@cookie_consent.route('/')
|
||||
def _cookies():
|
||||
from main import app
|
||||
resp = redirect('/')
|
||||
resp.set_cookie(
|
||||
key = 'cookie_consent',
|
||||
value = 'True',
|
||||
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else None,
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else None,
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
secure = True
|
||||
)
|
||||
return resp
|
234
ref-test/common/data_tools.py
Normal file
@ -0,0 +1,234 @@
|
||||
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([*enumerate(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([*enumerate(_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:
|
||||
submitted_answer = int(answers[str(q_no)])
|
||||
if submitted_answer == block['correct']:
|
||||
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:
|
||||
submitted_answer = int(answers[str(q_no)])
|
||||
if submitted_answer == question['correct']:
|
||||
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
@ -0,0 +1,60 @@
|
||||
from os import environ, path
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
def generate_keyfile():
|
||||
with open('./.security/.encryption.key', 'wb') as keyfile:
|
||||
key = Fernet.generate_key()
|
||||
keyfile.write(key)
|
||||
|
||||
def load_key():
|
||||
with open('./.security/.encryption.key', 'rb') as keyfile:
|
||||
key = keyfile.read()
|
||||
return key
|
||||
|
||||
def check_keyfile_exists():
|
||||
return path.isfile('./.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
@ -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)
|
@ -1 +1,51 @@
|
||||
from app.config import ProductionConfig as Config
|
||||
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")}/'
|
3
ref-test/data/.gitignore
vendored
@ -1 +1,2 @@
|
||||
*
|
||||
*
|
||||
!.gitignore
|
117
ref-test/main.py
@ -1,87 +1,84 @@
|
||||
from app.data import data
|
||||
from app.models import Entry, Dataset, Test, User
|
||||
from app.modules import bootstrap, csrf, db, login_manager, mail
|
||||
from app.tools.data import save
|
||||
from app.tools.logs import write
|
||||
from config import Config
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, Flask, render_template, request
|
||||
from flask import Flask, flash, request, render_template
|
||||
from flask.helpers import url_for
|
||||
from flask.json import jsonify
|
||||
from flask_wtf.csrf import CSRFError
|
||||
from sqlalchemy_utils import database_exists, create_database
|
||||
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 cryptography.fernet import Fernet
|
||||
from datetime import datetime
|
||||
from os import mkdir, path
|
||||
from common.security import check_keyfile_exists, generate_keyfile
|
||||
from config import ProductionConfig as Config
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config())
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto= 1, x_host= 1)
|
||||
|
||||
bootstrap.init_app(app)
|
||||
csrf.init_app(app)
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
mail.init_app(app)
|
||||
from common.blueprints import cookie_consent
|
||||
|
||||
login_manager.login_view = 'admin._login'
|
||||
@login_manager.user_loader
|
||||
def _load_user(id):
|
||||
return User.query.filter_by(id=id).first()
|
||||
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'):
|
||||
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. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
|
||||
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. 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(error):
|
||||
return render_template('404.html')
|
||||
def _404_handler(e):
|
||||
return render_template('/quiz/404.html'), 404
|
||||
|
||||
@app.errorhandler(CSRFError)
|
||||
def _csrf_handler():
|
||||
return jsonify({'error':'Could not validate a secure connection.'}), 403
|
||||
@app.context_processor
|
||||
def _now():
|
||||
return {'now': datetime.now()}
|
||||
def csrf_error_handler(error):
|
||||
return jsonify({ 'error': 'Could not validate a secure connection.'} ), 400
|
||||
|
||||
from app.admin.views import admin
|
||||
from app.api.views import api
|
||||
from app.quiz.views import quiz
|
||||
from app.views import views
|
||||
if not check_keyfile_exists():
|
||||
generate_keyfile()
|
||||
|
||||
app.register_blueprint(admin, url_prefix='/admin')
|
||||
app.register_blueprint(api, url_prefix='/api')
|
||||
app.register_blueprint(views)
|
||||
app.register_blueprint(quiz)
|
||||
Bootstrap(app)
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
|
||||
|
||||
if not path.isdir(f'./{data}'): mkdir(f'./{data}')
|
||||
if not path.isdir(f'./{data}/questions'): mkdir(f'./{data}/questions')
|
||||
if not path.isfile(f'./{data}/.gitignore'):
|
||||
with open(f'./{data}/.gitignore', 'a+') as file: file.write(f'*')
|
||||
if not path.isfile(f'./{data}/config.json'): save({}, 'config.json')
|
||||
if not path.isdir(f'./{data}/logs'): mkdir(f'./{data}/logs')
|
||||
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.')
|
||||
if not path.isfile(f'./{data}/logs/system.log'): write('system.log', 'Log file created.')
|
||||
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
|
||||
if not database_exists(Config.SQLALCHEMY_DATABASE_URI):
|
||||
create_database(Config.SQLALCHEMY_DATABASE_URI)
|
||||
write('system.log', 'No database found. Creating a new database.')
|
||||
with app.app_context(): db.create_all()
|
||||
write('system.log', 'Creating database schema.')
|
||||
if not path.isfile(f'./{data}/.encryption.key'):
|
||||
write('system.log', 'No encryption key found. Generating new encryption key.')
|
||||
with open(f'./{data}/.encryption.key', 'wb') as key_file:
|
||||
key = Fernet.generate_key()
|
||||
key_file.write(key)
|
||||
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()
|
||||
app.run(host=app.config['APP_HOST'])
|
@ -1,6 +1,6 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField
|
||||
from wtforms.validators import InputRequired, Length, Email, Optional
|
||||
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)])
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |