Compare commits

..

No commits in common. "43895bead0d1b0c449f9e9051af665d7748e396a" and "f170ff5e52b5cda69c0938eee821a906deefabe6" have entirely different histories.

119 changed files with 2135 additions and 2678 deletions

View File

@ -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
View File

@ -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.

View 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

View File

@ -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:

View File

@ -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
View 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;
}
}

View File

@ -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;
}

View File

@ -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
View 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

View File

@ -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)])

View 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

View 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

View 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 = '&rsquo;'.join([retrieved_user['username'], ''])
else:
_output = '&rsquo;'.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
View 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')

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -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') {

View File

@ -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>

View File

@ -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 %}

View File

@ -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() }}

View File

@ -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() }}

View File

@ -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() }}

View File

@ -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 }}&percnt;
{{ entry.results.score }}&percnt;
</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">

View 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>

View File

@ -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>

View File

@ -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 }}&percnt; ({{ result.result.grade }})
{{ result.percent }}&percnt; ({{ 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>

View File

@ -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 }}&percnt;
{{ entry.results.score }}&percnt;
</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>

View File

@ -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 }}&percnt;
{% if 'results' in entry %}
{{ entry.results.score }}&percnt;
{% 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>

View File

@ -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 &lsquo;{{ user.get_username() }}&rsquo;?</h2>
<h2 class="form-heading">Delete User &lsquo;{{ user.username }}&rsquo;?</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>

View File

@ -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>

View File

@ -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',
});

View File

@ -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 &lsquo;{{ user.get_username() }}&rsquo;</h2>
<h2 class="form-heading">Update User &lsquo;{{ user.username }}&rsquo;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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
View 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)

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -1,5 +0,0 @@
from config import Config
from os import path
from pathlib import Path
data = Path(Config.DATA)

View File

@ -1,4 +0,0 @@
from .entry import Entry
from .test import Test
from .user import User
from .dataset import Dataset

View File

@ -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

View File

@ -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)

View File

@ -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.'

View File

@ -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 '&lt;same as old&gt;'}</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.'

View File

@ -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()

View File

@ -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)

View File

@ -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;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

View File

@ -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();
})

View File

@ -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>

View File

@ -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&rsquo;s personal GIT repository</a> under an MIT License.</p>
<p>All questions in the test are &copy; 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>

View File

@ -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>&nbsp;<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>

View File

@ -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" />

View File

@ -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 %}

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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

View 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

View 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

View 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)

View 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)

View File

@ -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")}/'

View File

@ -1 +1,2 @@
*
!.gitignore

View File

@ -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'])

View File

@ -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)])

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Some files were not shown because too many files have changed in this diff Show More